Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
component.ref: Referencing Custom Element Instances (View Models)custom-attribute.ref: Referencing Custom Attribute Instances (View Models)controller.ref: Referencing Aurelia Controller Instances (Advanced)<input type="text" ref="firstNameInput" placeholder="First name"><p>You are typing: "${firstNameInput.value}"</p>import { customElement } from 'aurelia';
@customElement({ name: 'my-app', template: `<input type="text" ref="firstNameInput" placeholder="First name">` })
export class MyApp {
firstNameInput: HTMLInputElement; // Explicitly typed template reference
bound() {
// 'firstNameInput' is now available after the view is bound
console.log('Input element reference:', this.firstNameInput);
}
focusInput() {
if (this.firstNameInput) {
this.firstNameInput.focus(); // Programmatically focus the input element
}
}
}<my-custom-element component.ref="customElementViewModel"></my-custom-element>import { customElement } from 'aurelia';
import { MyCustomElement } from './my-custom-element'; // Assuming MyCustomElement is defined elsewhere
@customElement({ name: 'app', template: `<my-custom-element component.ref="customElementViewModel"></my-custom-element>` })
export class App {
customElementViewModel: MyCustomElement; // Typed reference to the custom element's view model
interactWithCustomElement() {
if (this.customElementViewModel) {
this.customElementViewModel.someMethodOnViewModel(); // Call a method on the custom element's view model
}
}
}<div my-custom-attribute custom-attribute.ref="customAttributeViewModel"></div>import { customElement } from 'aurelia';
import { MyCustomAttribute } from './my-custom-attribute'; // Assuming MyCustomAttribute is defined elsewhere
@customElement({ name: 'app', template: `<div my-custom-attribute custom-attribute.ref="customAttributeViewModel"></div>` })
export class App {
customAttributeViewModel: MyCustomAttribute; // Typed reference to the custom attribute's view model
useCustomAttribute() {
if (this.customAttributeViewModel) {
this.customAttributeViewModel.doSomethingWithAttribute(); // Call a method on the custom attribute's view model
}
}
}<my-custom-element controller.ref="customElementController"></my-custom-element>import { customElement, Controller } from 'aurelia';
@customElement({ name: 'app', template: `<my-custom-element controller.ref="customElementController"></my-custom-element>` })
export class App {
customElementController: Controller; // Typed reference to the custom element's Controller
accessControllerDetails() {
if (this.customElementController) {
console.log('Custom Element Controller:', this.customElementController);
// You can access lifecycle state, bindings, etc. through the controller
}
}
}export class TodoApp {
todos: Todo[] = [];
filter: string = 'all';
addTodo(text: string) {
// UI updates automatically when todos changes
this.todos.push({ id: Date.now(), text, completed: false });
}
removeTodo(index: number) {
// UI updates automatically
this.todos.splice(index, 1);
}
}<div>
<h2>Todos (${todos.length})</h2>
<input value.bind="filter" placeholder="Filter todos">
<ul>
<li repeat.for="todo of todos" if.bind="shouldShow(todo)">
${todo.text}
<button click.trigger="removeTodo($index)">Remove</button>
</li>
</ul>
</div>export class ShoppingCart {
items: CartItem[] = [];
get total() {
// This computed property automatically updates when items change
return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
get itemCount() {
// Also reactive - updates when items array changes
return this.items.length;
}
addItem(product: Product, quantity: number = 1) {
// UI updates automatically for total, itemCount, and items display
this.items.push({ ...product, quantity });
}
}<div class="cart">
<h3>Cart (${itemCount} items)</h3>
<div repeat.for="item of items" class="cart-item">
<span>${item.name}</span>
<span>$${item.price} x ${item.quantity}</span>
</div>
<div class="total">Total: $${total}</div>
</div>import { computed } from 'aurelia';
export class ShoppingCart {
items: CartItem[] = [];
// we only care when there's a change in the number of items
// but not when the price or quantity of each item changes
@computed('items.length')
get total() {
// This computed property automatically updates when items change
return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
// other code ...
}import { computed } from 'aurelia';
export class ShoppingCart {
items: CartItem[] = [];
gst = .1;
// we only care when there's a change in the number of items
// but not when the price or quantity of each item changes
@computed('items.length', 'gst')
get total() {
// This computed property automatically updates when items change
return this.items.reduce((sum, item) => sum + (item.price * this.tax * item.quantity), 0);
}
get tax() {
return 1 + this.gst;
}
// other code ...
}import { computed } from 'aurelia';
export class ProductList {
filter = '';
products: Product[] = [];
nested = { prop: '' };
prop = '';
prop2 = '';
// proxy-based tracking (default)
@computed
matches(product: Product) {
return product.name.includes(this.filter);
}
// explicit dependency paths
@computed('filter', 'nested.prop')
matches2(product: Product) {
return product.name.includes(this.filter);
}
// explicit dependency function
@computed((instance: ProductList) => instance.prop + instance.prop2)
matches3(product: Product) {
return product.name.includes(this.filter);
}
// options form with string deps
@computed({ deps: ['filter', 'nested.prop'] })
matches4(product: Product) {
return product.name.includes(this.filter);
}
// options form with getter function dep
@computed({ deps: (instance: ProductList) => instance.prop + instance.prop2 })
matches5(product: Product) {
return product.name.includes(this.filter);
}
}import { computed } from 'aurelia';
export class ShoppingCart {
items: CartItem[] = [];
gst = .1;
// we only care when there's a change in the number of items, or gst
// but not when the price or quantity of each item changes
@computed({
deps: ['items.length', 'gst'],
flush: 'sync'
})
get total() {
// This computed property automatically updates when items change
return this.items.reduce((sum, item) => sum + (item.price * this.tax * item.quantity), 0);
}
get tax() {
return 1 + this.gst;
}
// other code ...
}import { computed } from 'aurelia';
export class ShoppingCart {
_cart = {
items: [],
gst: 0.1,
}
// we care about any changes inside cart items, or gst
@computed({
deps: ['_cart'],
deep: true,
})
get total() {
// This computed property automatically updates when items change
return this._cart.items.reduce((sum, item) => sum + (item.price * this._cart.gst * item.quantity), 0);
}
get tax() {
return 1 + this.gst;
}
// other code ...
}export class UserProfile {
user = {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'Anytown',
country: 'USA'
},
preferences: {
theme: 'dark',
notifications: true
}
};
updateAddress(newAddress: Partial<Address>) {
// Nested property changes are automatically detected
Object.assign(this.user.address, newAddress);
}
}export class TaskList {
tasks: Task[] = [];
addTask(task: Task) {
this.tasks.push(task); // Automatically triggers UI update
}
completeTask(index: number) {
this.tasks[index].completed = true; // Property change observed
}
removeTasks(indices: number[]) {
// Multiple array changes batched into single UI update
indices.sort((a, b) => b - a).forEach(index => {
this.tasks.splice(index, 1);
});
}
}import { observable } from 'aurelia';
export class UserProfile {
@observable userName: string = '';
// This method is called whenever userName changes
userNameChanged(newValue: string, oldValue: string) {
console.log(`Username changed from ${oldValue} to ${newValue}`);
this.validateUsername(newValue);
}
private validateUsername(name: string) {
// Perform validation when username changes
}
}<!-- userName is still automatically observed for template updates -->
<input value.bind="userName">
<p>Hello, ${userName}!</p>import { watch } from 'aurelia';
export class Analytics {
currentPage: string = '/';
user: User | null = null;
constructor() {
// Watch properties and react to changes
watch(() => this.currentPage, (newPage) => {
this.trackPageView(newPage);
});
watch(() => this.user, (newUser, oldUser) => {
if (oldUser) this.trackUserLogout(oldUser);
if (newUser) this.trackUserLogin(newUser);
});
}
private trackPageView(page: string) {
console.log(`Page view: ${page}`);
}
}import { resolve } from '@aurelia/kernel';
import { IObserverLocator } from '@aurelia/runtime';
export class AdvancedComponent {
private observerLocator = resolve(IObserverLocator);
data = { value: 0 };
attached() {
// Manually observe a property
const observer = this.observerLocator.getObserver(this.data, 'value');
observer.subscribe((newValue, oldValue) => {
console.log(`Value changed: ${oldValue} -> ${newValue}`);
});
}
}import { observable } from 'aurelia';
export class RegistrationForm {
@observable email: string = '';
@observable password: string = '';
emailError: string = '';
passwordError: string = '';
emailChanged(newValue: string) {
// Run validation whenever email changes
if (!newValue) {
this.emailError = 'Email is required';
} else if (!newValue.includes('@')) {
this.emailError = 'Please enter a valid email';
} else {
this.emailError = '';
}
}
passwordChanged(newValue: string) {
if (newValue.length < 8) {
this.passwordError = 'Password must be at least 8 characters';
} else {
this.passwordError = '';
}
}
get isValid(): boolean {
return !this.emailError && !this.passwordError && this.email && this.password;
}
}<form>
<input value.bind="email" type="email" placeholder="Email">
<span class="error" if.bind="emailError">${emailError}</span>
<input value.bind="password" type="password" placeholder="Password">
<span class="error" if.bind="passwordError">${passwordError}</span>
<button disabled.bind="!isValid" click.trigger="submit()">Register</button>
</form>import { observable } from 'aurelia';
export class ProductCatalog {
products: Product[] = [];
@observable searchQuery: string = '';
@observable sortBy: 'name' | 'price' = 'name';
@observable maxPrice: number = 1000;
get filteredProducts(): Product[] {
// Automatically recomputes when searchQuery, maxPrice, or products change
return this.products
.filter(p =>
p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) &&
p.price <= this.maxPrice
)
.sort((a, b) => {
if (this.sortBy === 'price') {
return a.price - b.price;
}
return a.name.localeCompare(b.name);
});
}
get resultCount(): number {
return this.filteredProducts.length;
}
async binding() {
const response = await fetch('/api/products');
this.products = await response.json();
}
}<div class="catalog">
<input value.bind="searchQuery" placeholder="Search products...">
<select value.bind="sortBy">
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
<input type="range" min="0" max="1000" value.bind="maxPrice">
<span>Max: $${maxPrice}</span>
<p>${resultCount} products found</p>
<div repeat.for="product of filteredProducts" class="product-card">
<h3>${product.name}</h3>
<p>$${product.price}</p>
</div>
</div>import { watch, observable } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { ILogger } from '@aurelia/kernel';
export class DraftEditor {
private logger = resolve(ILogger);
@observable content: string = '';
@observable title: string = '';
lastSaved: Date | null = null;
isSaving: boolean = false;
constructor() {
// Load from localStorage on startup
this.content = localStorage.getItem('draft-content') || '';
this.title = localStorage.getItem('draft-title') || '';
}
// Watch for changes and auto-save
@watch('content')
@watch('title')
async contentChanged() {
if (this.isSaving) return;
this.isSaving = true;
try {
// Save to localStorage immediately
localStorage.setItem('draft-content', this.content);
localStorage.setItem('draft-title', this.title);
// Debounced server sync (implement as needed)
await this.syncToServer();
this.lastSaved = new Date();
this.logger.debug('Draft saved');
} finally {
this.isSaving = false;
}
}
private async syncToServer() {
// Sync to server with debouncing
}
}<div class="editor">
<input value.bind="title" placeholder="Title">
<textarea value.bind="content" placeholder="Start writing..."></textarea>
<div class="status">
<span if.bind="isSaving">Saving...</span>
<span if.bind="lastSaved && !isSaving">
Saved at ${lastSaved.toLocaleTimeString()}
</span>
</div>
</div>export class OrderSummary {
items: OrderItem[] = [];
@observable discountCode: string = '';
@observable taxRate: number = 0.08;
get subtotal(): number {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
}
get discount(): number {
// Depends on subtotal and discountCode
if (this.discountCode === 'SAVE10') {
return this.subtotal * 0.1;
}
if (this.discountCode === 'SAVE20') {
return this.subtotal * 0.2;
}
return 0;
}
get afterDiscount(): number {
// Depends on subtotal and discount
return this.subtotal - this.discount;
}
get tax(): number {
// Depends on afterDiscount and taxRate
return this.afterDiscount * this.taxRate;
}
get total(): number {
// Final total depends on afterDiscount and tax
return this.afterDiscount + this.tax;
}
}<div class="order-summary">
<p>Subtotal: $${subtotal.toFixed(2)}</p>
<input value.bind="discountCode" placeholder="Discount code">
<p if.bind="discount > 0" class="discount">
Discount: -$${discount.toFixed(2)}
</p>
<p>Tax (${(taxRate * 100).toFixed(0)}%): $${tax.toFixed(2)}</p>
<p class="total">Total: $${total.toFixed(2)}</p>
</div>import { computed } from 'aurelia';
export class DataAnalytics {
dataPoints: DataPoint[] = []; // Large array
@observable dateRange: DateRange;
@observable selectedMetric: string = 'sales';
// Only recalculate when dependencies actually change
@computed('dataPoints.length', 'dateRange', 'selectedMetric')
get filteredData(): DataPoint[] {
console.log('Filtering data (expensive)');
return this.dataPoints.filter(point =>
point.date >= this.dateRange.start &&
point.date <= this.dateRange.end &&
point.metric === this.selectedMetric
);
}
@computed('filteredData.length')
get statistics(): Statistics {
console.log('Computing statistics (expensive)');
const values = this.filteredData.map(d => d.value);
return {
mean: this.mean(values),
median: this.median(values),
stdDev: this.standardDeviation(values)
};
}
// Heavy computation methods
private mean(values: number[]): number { /* ... */ }
private median(values: number[]): number { /* ... */ }
private standardDeviation(values: number[]): number { /* ... */ }
}Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.
Bind an element's focus state with Aurelia's built-in focus custom attribute.
npx makes aurelia
# Name: project-pulse
# Select TypeScript
cd project-pulse
npm run devimport Aurelia from 'aurelia';
import { RouterConfiguration } from '@aurelia/router';
import { MyApp } from './my-app';
Aurelia
.register(RouterConfiguration.customize({ activeClass: 'is-active' }))
.app(MyApp)
.start();export class AppShell {}<div class="shell">
<header class="shell__header">
<div class="shell__title">
<au-slot name="title">Project Pulse</au-slot>
</div>
<div class="shell__actions">
<au-slot name="actions"></au-slot>
</div>
</header>
<main class="shell__body">
<au-slot></au-slot>
</main>
</div><input focus.bind="isFocused">
<button click.trigger="isFocused = true">Focus the input</button>
<button click.trigger="isFocused = false">Blur the input</button>export class MyPage {
public isFocused = false;
}<input focus.to-view="shouldFocusSearch"><button click.trigger="isOpen = !isOpen">Toggle</button>
<div if.bind="isOpen">
<input focus.to-view="isOpen" placeholder="Type to search...">
<!-- Focuses when opened; does not auto-close on blur -->
</div>Get acquainted with Aurelia, the documentation, and how to get started.
Learn how to define, use, and optimize local (inline) templates in Aurelia 2 to remove boilerplate and simplify your components.
<template as-custom-element="person-info">
<bindable name="person"></bindable>
<div>
<label>Name:</label>
<span>${person.name}</span>
</div>
<div>
<label>Address:</label>
<span>${person.address}</span>
</div>
</template>
<h2>Sleuths</h2>
<person-info repeat.for="sleuth of sleuths" person.bind="sleuth"></person-info>
<h2>Nemeses</h2>
<person-info
repeat.for="nemesis of nemeses"
person.bind="nemesis"
></person-info>../ when you want to navigate to a sibling at the parent level.detail in the URL to keep the child routes explicit and readable. The id: 'project-detail' value is what the Overview page uses in its route: instruction, with a ../ prefix to resolve against the parent context.export class MyApp {
public readonly sleuths: Person[] = [
new Person("Byomkesh Bakshi", "66, Harrison Road"),
new Person("Sherlock Holmes", "221b Baker Street"),
];
public readonly nemeses: Person[] = [
new Person("Anukul Guha", "unknown"),
new Person("James Moriarty", "unknown"),
];
}
class Person {
public constructor(public name: string, public address: string) {}
}<section with.bind="user">
<h2>${firstName} ${lastName}</h2>
</section><template with.bind="{ profile: user.profile, canEdit: permissions.admin }">
<user-profile profile.bind="profile"></user-profile>
<button disabled.bind="!canEdit">Edit</button>
</template><template with.bind="user || {}">
${firstName}
</template>export class DashboardPage {}<import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">Dashboard</h1>
<a au-slot="actions" load="../projects">View Projects</a>
<p>Welcome to Project Pulse. Use the Projects page to manage tasks.</p>
</app-shell>export class ProjectsOverviewPage {}<p>Projects overview goes here.</p>export class ProjectsActivityPage {}<p>Activity summary goes here.</p>import { route } from '@aurelia/router';
import { ProjectsActivityPage } from './projects-activity-page';
import { ProjectsOverviewPage } from './projects-overview-page';
@route({
routes: [
{ path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
{ path: 'activity', component: ProjectsActivityPage, title: 'Activity' }
]
})
export class ProjectsPage {}<import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">Projects</h1>
<a au-slot="actions" load="../dashboard">Back to Dashboard</a>
<nav class="projects-subnav">
<a load="">Overview</a>
<a load="activity">Activity</a>
</nav>
<au-viewport></au-viewport>
</app-shell>import { route } from '@aurelia/router';
import { DashboardPage } from './pages/dashboard-page';
import { ProjectsPage } from './pages/projects-page';
@route({
routes: [
{ path: ['', 'dashboard'], component: DashboardPage, title: 'Dashboard' },
{ path: 'projects', component: ProjectsPage, title: 'Projects' }
]
})
export class MyApp {}<nav class="main-nav">
<a load="dashboard">Dashboard</a>
<a load="projects">Projects</a>
</nav>
<au-viewport></au-viewport>import { route } from '@aurelia/router';
import { ProjectDetailPage } from './project-detail-page';
import { ProjectsActivityPage } from './projects-activity-page';
import { ProjectsOverviewPage } from './projects-overview-page';
@route({
routes: [
{ path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
{ path: 'activity', component: ProjectsActivityPage, title: 'Activity' },
{ id: 'project-detail', path: 'detail/:id', component: ProjectDetailPage, title: 'Project Detail' }
]
})
export class ProjectsPage {}import { IRouteViewModel, Params } from '@aurelia/router';
import { Project } from '../models';
import { PROJECTS } from '../project-data';
export class ProjectDetailPage implements IRouteViewModel {
projectId = '';
project: Project | null = null;
noteDraft = '';
canLoad(params: Params): boolean | string {
const candidate = PROJECTS.find(item => item.id === params.id);
return candidate ? true : 'projects';
}
loading(params: Params): void {
this.projectId = params.id ?? '';
this.project = PROJECTS.find(item => item.id === this.projectId) ?? null;
}
canUnload(): boolean {
if (!this.noteDraft.trim()) return true;
return confirm('You have an unsaved note. Leave this page?');
}
saveNote(): void {
this.noteDraft = '';
}
}<import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">${project?.name ?? 'Project'}</h1>
<a au-slot="actions" load="../overview">Back to Projects</a>
<section if.bind="project">
<p class="project-meta">Project id: ${projectId}</p>
<label class="note">
<span>Note</span>
<textarea value.bind="noteDraft" placeholder="Add a quick note"></textarea>
</label>
<button if.bind="noteDraft" click.trigger="saveNote()">Save Note</button>
<ul class="task-list">
<li repeat.for="task of project.tasks">
<span class.bind="task.done ? 'task-item__done' : ''">${task.title}</span>
</li>
</ul>
</section>
<p if.bind="!project">Project not found.</p>
</app-shell># Recipe Name
Brief description of what this recipe demonstrates.
## Live Demo
[Open in StackBlitz](link-to-stackblitz)
## Features Demonstrated
- Feature 1
- Feature 2
- Feature 3
## Code
### View Model (TypeScript)
[code]
### Template (HTML)
[code]
### Styles (CSS) - Optional
[code]
## How It Works
Step-by-step explanation...
## Variations
- Variation 1: Description and code
- Variation 2: Description and code
## Related
- [Related Recipe](link)
- [Related Docs](link)Learn how to style elements, components and other facets of an Aurelia application using classes and CSS. Strategies for different approaches are discussed in this section.
Bind CSS classes and inline styles in Aurelia templates using expressive syntax.
Add router event listeners and polish the navigation experience.
import { IDisposable, resolve } from '@aurelia/kernel';
import { IRouterEvents, NavigationEndEvent, route } from '@aurelia/router';
import { ProjectsActivityPage } from './projects-activity-page';
import { ProjectsOverviewPage } from './projects-overview-page';
@route({
routes: [
{ path: ['', 'overview'], component: ProjectsOverviewPage, title: 'Overview' },
{ path: 'activity', component: ProjectsActivityPage, title: 'Activity' }
]
})
export class ProjectsPage {
lastNavigation = '';
private readonly events = resolve(IRouterEvents);
private subscription?: IDisposable;
bound(): void {
this.subscription = this.events.subscribe(
'au:router:navigation-end',
(event: NavigationEndEvent) => {
this.lastNavigation = event.instructions.toPath();
}
);
}
unbinding(): void {
this.subscription?.dispose();
}
}Learn about binding values to attributes of DOM elements and how to extend the attribute mapping with great ease.
IRouter.load<input value.bind="message"> and automatically get a two-way binding. By teaching the mapper about your own elements, you can bring the same ergonomics to Web Components, design systems, or DSLs.<h1>${title}</h1>
<p>Welcome, ${user.firstName} ${user.lastName}!</p><!-- Property binding -->
<input value.bind="message">
<img src.bind="imageUrl" alt.bind="imageAlt">
<!-- Attribute binding -->
<div class.bind="cssClass" id.bind="elementId">
<!-- Boolean attributes -->
<button disabled.bind="isLoading">Submit</button><input value.bind="searchQuery">
<p>Searching for: ${searchQuery}</p><button click.trigger="save()">Save</button>
<form submit.trigger="handleSubmit($event)">
<input keyup.trigger="validateInput($event)">
</form>export class MyComponent {
save() {
console.log('Saving...');
}
handleSubmit(event: Event) {
event.preventDefault();
// Handle form submission
}
validateInput(event: KeyboardEvent) {
// Validate as user types
}
}<!-- Show/hide elements -->
<div if.bind="isLoggedIn">
<p>Welcome back!</p>
</div>
<div else>
<p>Please log in</p>
</div>
<!-- Conditionally show content -->
<p show.bind="hasMessages">You have new messages</p>
<p hide.bind="isLoading">Content loaded</p><ul>
<li repeat.for="item of items">${item.name}</li>
</ul>
<!-- With index -->
<div repeat.for="product of products">
<h3>${$index + 1}. ${product.title}</h3>
<p>${product.description}</p>
</div><input ref="searchInput" value.bind="query">
<button click.trigger="focusSearch()">Focus Search</button>export class MyComponent {
searchInput: HTMLInputElement;
focusSearch() {
this.searchInput.focus();
}
}<div with.bind="user">
<h2>${firstName} ${lastName}</h2>
<p>${email}</p>
</div>
<!-- Using let for computed values -->
<div let="fullName.bind="firstName + ' ' + lastName">
<h2>${fullName}</h2>
</div><import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">Projects</h1>
<a au-slot="actions" load="../dashboard">Back to Dashboard</a>
<nav class="projects-subnav">
<a load="./">Overview</a>
<a load="activity">Activity</a>
</nav>
<p class="nav-meta" if.bind="lastNavigation">
Last navigation: ${lastNavigation}
</p>
<au-viewport></au-viewport>
</app-shell>import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
Aurelia.register(
AppTask.creating(IAttrMapper, attrMapper => {
attrMapper.useMapping({
'MY-CE': { 'fizz-buzz': 'FizzBuzz' },
INPUT: { 'fizz-buzz': 'fizzbuzz' },
});
attrMapper.useGlobalMapping({
'foo-bar': 'FooBar',
});
})
);<input fizz-buzz.bind="userLimit" foo-bar.bind="hint" ref="input">
<my-ce fizz-buzz.bind="42" foo-bar.bind="43" ref="myCe"></my-ce>export class App {
private input!: HTMLInputElement;
private myCe!: HTMLElement & { FizzBuzz?: number; FooBar?: number };
public attached() {
console.log(this.input.fizzbuzz); // userLimit
console.log(this.myCe.FizzBuzz); // 42
}
}import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
Aurelia.register(
AppTask.creating(IAttrMapper, attrMapper => {
attrMapper.useTwoWay((element, attrName) =>
element.tagName === 'MY-CE' && attrName === 'fizz-buzz');
})
);<!-- Recommended: put the expression in the attribute value -->
<user-card ...$bindables="user"></user-card>
<!-- Equivalent: explicit binding command form -->
<user-card $bindables.spread="user"></user-card>
<!-- Shorthand: put the expression in the attribute name (no spaces!) -->
<user-card ...user></user-card>import { bindable } from 'aurelia';
export class UserCard {
@bindable name!: string;
@bindable email!: string;
@bindable avatarUrl!: string;
}export class MyApp {
user = {
name: 'Jane Doe',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.jpg',
extra: 'ignored'
};
}<user-card ...$bindables="user"></user-card>import { customElement, bindable } from 'aurelia';
@customElement({
name: 'form-input',
capture: true,
template: `
<label>
\${label}
<input ...$attrs>
</label>
`
})
export class FormInput {
@bindable label!: string;
@bindable value!: string;
}<form-input
label="Email"
value.bind="email"
placeholder="[email protected]"
input.trigger="validate($event)">
</form-input><!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Aurelia 2 Quick Try</title>
<base href="/" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net">
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/aurelia@latest/+esm" crossorigin fetchpriority="high">
</head>
<body>
<app-root></app-root>
<script type="module">
import { Aurelia, CustomElement } from 'https://cdn.jsdelivr.net/npm/aurelia@latest/+esm';
const App = CustomElement.define({
name: 'app-root',
template: `
<h1>Hello, \${name}!</h1>
<input value.bind="name" placeholder="Enter your name">
<p>You typed: \${name}</p>
`
}, class {
name = 'World';
});
new Aurelia()
.app({ component: App, host: document.querySelector('app-root') })
.start();
</script>
</body>
</html>npx makes aureliacd your-project-name
npm start<p selected.class="isSelected">I am selected (I think)</p><div alert,alert-danger,fade-in,bold-text.class="hasError">Something went wrong!</div><p background.style="bg">My background is blue</p>export class MyApp {
private backgroundColor = 'black';
private textColor = '#FFF';
}<p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>export class MyApp {
private styleObject = {
background: 'black',
color: '#FFF'
};
}<p style.bind="styleObject">Hello there</p><p selected.class="isSelected">I am selected (I think)</p><p background.style="bg">My background is blue</p>export class MyApp {
private backgroundColor = 'black';
private textColor = '#FFF';
}<p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>
export class MyApp {
private styleObject = {
background: 'black',
color: '#FFF'
};
}<p style.bind="styleObject">Hello there</p>
<let><let><let>Use Aurelia's built-in template commands such as if, show, repeat, and switch to control markup dynamically.
The Aurelia template compiler is powerful and developer-friendly, allowing you extend its binding language with great ease.
New to Javascript, Node.js and front-end development in general? Don't worry, we got you.
Learn how Aurelia 2 handles global variables in templates, the built-in list of accessible globals, and when to use them effectively.
<let>true<let variable-name="variable value"></let><let greeting-message="Hello, Aurelia!"></let><p>${greetingMessage}</p><p>Hello, Aurelia!</p><let calculation-result.bind="10 + 5 * 2"></let><p>The result is: ${calculationResult}</p><p>The result is: 20</p><let user-name.bind="userName"></let>
<h1>Welcome, ${userName}!</h1>
<p>Your username variable (from <let>) is: ${userName}</p>
<p>Your username property (from view model) is: ${userName}</p>export class MyApp {
userName = 'John Doe';
}<let is-evening.bind="currentHour >= 18"></let>
<let time-of-day-message.bind="isEvening ? 'Good evening' : 'Good day'"></let>
<p>${timeOfDayMessage}, user!</p>export class MyApp {
currentHour = new Date().getHours();
}<ul>
<template repeat.for="item of items">
<li>
<let item-index.bind="$index"></let>
Item ${itemIndex + 1}: ${item.name}
</li>
</template>
</ul><p>Total price (excluding tax): $${quantity * price}</p>
<p>Tax amount (10%): $${(quantity * price) * 0.10}</p>
<p>Final price (including tax): $${(quantity * price) * 1.10}</p><let subtotal.bind="quantity * price"></let>
<p>Total price (excluding tax): $${subtotal}</p>
<p>Tax amount (10%): $${subtotal * 0.10}</p>
<p>Final price (including tax): $${subtotal * 1.10}</p><let show-details.bind="isDetailsVisible"></let>
<button click.trigger="isDetailsVisible = !isDetailsVisible">
${showDetails ? 'Hide Details' : 'Show Details'}
</button>
<div if.bind="showDetails">
<!-- Details content here -->
<p>Detailed information is displayed.</p>
</div><let formatted-date.bind="new Date().toLocaleDateString()"></let>
<p>Today's date: ${formattedDate}</p><div if.bind="isLoading">Loading...</div><div show.bind="isLoading">Loading...</div><p switch.bind="selectedAction">
<span case="mask">You are more protected from aerosol particles, and others are protected from you.</span>
<span case="sanitizer">You are making sure viruses won't be spreaded easily.</span>
<span case="wash">You are helping eliminate the virus.</span>
<span case="all">You are protecting yourself and people around you. You rock!</span>
<span default-case>Unknown.</span>
</p><let i.bind="0"></let>
<div promise.bind="fetchAdvice(i)">
<span pending>Fetching advice...</span>
<span then="data">
Advice id: ${data.slip.id}<br>
${data.slip.advice}
<button click.trigger="i = i+1">try again</button>
</span>
<span catch="err">
Cannot get an addvice, error: ${err}
<button click.trigger="i = i+1">try again</button>
</span>
</div>export class MyApp {
fetchAdvice() {
return fetch("https://api.adviceslip.com/advice")
.then(r => r.ok ? r.json() : (() => { throw new Error('Unable to fetch NASA APOD data') }))
}
}
<ul>
<li repeat.for="item of items">${item.name}</li>
</ul>for (let item of items) {
console.log(item.name);
}<p repeat.for="i of 10">${10-i}</p>
<p>Blast Off!<p><ul>
<li repeat.for="item of items">${$index}</li>
</ul>npx makes aurelianpm run build<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>export class FileUploadComponent {
selectedFiles: File[] = [];
handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
this.selectedFiles = Array.from(input.files);
}
async uploadFiles() {
if (this.selectedFiles.length === 0) return;
const formData = new FormData();
for (const file of this.selectedFiles) {
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);
this.selectedFiles = [];
} catch (error) {
console.error('Error uploading files:', error);
}
}
}<input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
this.selectedFiles = input.files?.length ? [input.files[0]] : [];
}export class FilePreviewComponent {
selectedFile: File | null = null;
previewUrl: string | null = null;
handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
this.selectedFile = file;
this.createPreview(file);
}
}
private createPreview(file: File) {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = URL.createObjectURL(file);
}
detaching() {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
}
}
}<input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />
<div if.bind="previewUrl" class="preview">
<img src.bind="previewUrl" alt="Preview" />
<p>${selectedFile.name} (${(selectedFile.size / 1024).toFixed(2)} KB)</p>
</div>export class ValidatedFileUpload {
selectedFile: File | null = null;
error: string | null = null;
maxSize = 5 * 1024 * 1024; // 5MB
allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
handleFileSelect(event: Event) {
this.error = null;
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!this.allowedTypes.includes(file.type)) {
this.error = 'Only JPEG, PNG, and GIF images are allowed';
input.value = '';
return;
}
if (file.size > this.maxSize) {
this.error = `File size must be less than ${this.maxSize / (1024 * 1024)}MB`;
input.value = '';
return;
}
this.selectedFile = file;
}
}export class FileUploadWithProgress {
uploadProgress = 0;
isUploading = false;
async uploadWithProgress(file: File) {
this.isUploading = true;
this.uploadProgress = 0;
const xhr = new XMLHttpRequest();
return new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
this.uploadProgress = (e.loaded / e.total) * 100;
}
});
xhr.addEventListener('load', () => {
this.isUploading = false;
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
this.isUploading = false;
reject(new Error('Upload failed'));
});
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}
}<input type="file" change.trigger="handleFileSelect($event)" />
<div if.bind="isUploading" class="progress">
<div class="progress-bar" css="width: ${uploadProgress}%"></div>
<span>${uploadProgress.toFixed(0)}%</span>
</div><pre>${JSON.stringify(user, null, 2)}</pre><p>The square root of 16 is: ${Math.sqrt(16)}</p><input type="text" value.bind="value" />
<p if.bind="isNaN(value)">This is not a valid number!</p><input value.bind="email" placeholder="Enter email" />
<p if.bind="new RegExp('^\\S+@\\S+\\.\\S+$').test(email)">
Valid Email Address
</p><p>Property Value: ${Object.getOwnPropertyDescriptor(user, selectedProp)?.value}</p><p>Unique Values: ${[...new Set(numbersArray)]}</p><a href.bind="encodeURI(externalLink)">Visit External Site</a>
<p>Original URL: ${decodeURI(externalLink)}</p><p>Price: ${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)}</p><p>Active Items: ${Array.from(dataSet).filter(i => i.active).map(i => i.name).join(', ')}</p>interface BindingCommandInstance {
ignoreAttr: boolean;
build(info: ICommandBuildInfo, parser: IExpressionParser, mapper: IAttrMapper): IInstruction;
}<div foo.bar.bs="ev => handleCustomEvent(ev)"></div><div bs.foo.bar.trigger="ev => handleCustomEvent(ev)"></div>import { IExpressionParser } from 'aurelia';
import {
BindingCommandInstance,
ICommandBuildInfo,
ListenerBindingInstruction,
bindingCommand,
} from '@aurelia/template-compiler';
@bindingCommand('bs')
export class BsBindingCommand implements BindingCommandInstance {
public ignoreAttr = true; // we fully own attributes that end with .bs
public build(
info: ICommandBuildInfo,
exprParser: IExpressionParser,
) {
return new ListenerBindingInstruction(
/* from */ exprParser.parse(info.attr.rawValue, 'IsFunction'),
/* to */ `bs.${info.attr.target}`,
/* preventDefault */ true,
/* capture */ false,
);
}
}import Aurelia from 'aurelia';
import { BsBindingCommand } from './bs-binding-command';
import { MyRoot } from './my-root';
Aurelia
.register(BsBindingCommand)
.app(MyRoot)
.start();<div foo.bar.bs="ev => handleCustomEvent(ev)"></div>
<!-- ^^
|_________ custom binding command
-->Learn about the various methods for conditionally rendering content in Aurelia 2, with detailed explanations and examples.
if.bindshow.bindhide.bind (inverse of show.bind)switch.bindUse route data for roles and enforce access with a router hook.
import { DI } from '@aurelia/kernel';
export type User = {
name: string;
roles: string[];
};
export const IAuthService = DI.createInterface<IAuthService>(
'IAuthService',
x => x.singleton(AuthService)
);
export interface IAuthService extends AuthService {}
export class AuthService {
private user: User | null = { name: 'Taylor', roles: ['member'] };
getCurrentUser(): User | null {
return this.user;
}
hasRole(role: string): boolean {
return !!this.user?.roles.includes(role);
}
toggleRole(role: string): void {
if (!this.user) return;
if (this.user.roles.includes(role)) {
this.user = { ...this.user, roles: this.user.roles.filter(item => item !== role) };
return;
}
this.user = { ...this.user, roles: [...this.user.roles, role] };
}
}A developer guide for enabling SVG binding in Aurelia 2.
import { SVGAnalyzer } from '@aurelia/runtime-html';
import { Aurelia } from 'aurelia';
Aurelia
.register(SVGAnalyzer) // <-- add this line
.app(MyApp)
.start();Master the art of scope and binding context - the secret sauce behind Aurelia's powerful data binding magic.
texttspantextPathhref<div if.bind="isLoading">Loading...</div>
<div if.bind="user.isAuthenticated">Welcome back, ${user.name}!</div><div if.bind="user.isAuthenticated">
Welcome back, ${user.name}!
</div>
<div else>
Please log in to continue.
</div><custom-element if="value.bind: canShow; cache: false"></custom-element><div show.bind="isDataLoaded">Data loaded successfully!</div>
<div show.bind="!isLoading">Content is ready</div><div hide.bind="isHidden">Hidden when true</div><div show.bind="!isHidden">Hidden when true</div><!-- Use show.bind for frequent toggles -->
<div show.bind="isExpanded">
<expensive-component></expensive-component>
</div>
<!-- Use if.bind for infrequent changes -->
<admin-panel if.bind="user.isAdmin"></admin-panel>// Status.ts
enum OrderStatus {
Received = 'received',
Processing = 'processing',
Dispatched = 'dispatched',
Delivered = 'delivered'
}<!-- order-status.html -->
<template switch.bind="orderStatus">
<span case="received">Order received</span>
<span case="processing">Processing your order</span>
<span case="dispatched">On the way</span>
<span case="delivered">Delivered</span>
<span default-case>Unknown status</span>
</template><template switch.bind="orderStatus">
<span case.bind="['received', 'processing']">
Order is being processed
</span>
<span case="dispatched">On the way</span>
<span case="delivered">Delivered</span>
</template><template switch.bind="orderStatus">
<span case="received" fall-through="true">Order received</span>
<span case="processing">Processing your order</span>
</template><template repeat.for="num of numbers">
<template switch.bind="true">
<span case.bind="num % 15 === 0">FizzBuzz</span>
<span case.bind="num % 3 === 0">Fizz</span>
<span case.bind="num % 5 === 0">Buzz</span>
<span default-case>${num}</span>
</template>
</template><template as-custom-element="status-card">
<au-slot name="content"></au-slot>
</template>
<status-card>
<template au-slot="content" switch.bind="status">
<div case="loading">Loading...</div>
<div case="error">Something went wrong</div>
<div case="success">Operation completed</div>
</template>
</status-card><template switch.bind="userRole">
<div case="admin">
<template switch.bind="adminSection">
<admin-users case="users"></admin-users>
<admin-settings case="settings"></admin-settings>
<admin-dashboard default-case></admin-dashboard>
</template>
</div>
<user-dashboard case="user"></user-dashboard>
<guest-welcome default-case></guest-welcome>
</template><!-- Good: Group related conditions -->
<template switch.bind="appState">
<loading-screen case="loading"></loading-screen>
<error-screen case="error"></error-screen>
<main-content case="ready"></main-content>
</template>
<!-- Avoid: Multiple separate if statements -->
<loading-screen if.bind="appState === 'loading'"></loading-screen>
<error-screen if.bind="appState === 'error'"></error-screen>
<main-content if.bind="appState === 'ready'"></main-content><!-- ✅ Correct -->
<template switch.bind="status">
<span case="active">Active</span>
</template>
<!-- ❌ Incorrect: case not direct child -->
<template switch.bind="status">
<div if.bind="someCondition">
<span case="active">Active</span>
</div>
</template><template switch.bind="status">
<span case="received">Received</span>
<span case="processing">Processing</span>
<span default-case>Unknown</span> <!-- Last -->
</template>import { resolve } from '@aurelia/kernel';
import { lifecycleHooks } from '@aurelia/runtime-html';
import { IRouteViewModel, Params, RouteNode } from '@aurelia/router';
import { IAuthService } from './services/auth-service';
@lifecycleHooks()
export class RoleHook {
private readonly auth = resolve(IAuthService);
canLoad(_viewModel: IRouteViewModel, _params: Params, next: RouteNode): boolean | string {
const requiredRoles = next.data?.roles as string[] | undefined;
const fallbackRoute = (next.data?.fallbackRoute as string | undefined) ?? 'forbidden';
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const hasRequiredRole = requiredRoles.some(role => this.auth.hasRole(role));
return hasRequiredRole ? true : fallbackRoute;
}
}import Aurelia from 'aurelia';
import { RouterConfiguration } from '@aurelia/router';
import { MyApp } from './my-app';
import { RoleHook } from './role-hook';
Aurelia
.register(
RouterConfiguration.customize({ activeClass: 'is-active' }),
RoleHook
)
.app(MyApp)
.start();export class AdminPage {}<import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">Admin</h1>
<a au-slot="actions" load="../dashboard">Back to Dashboard</a>
<p>Only admins can access this page.</p>
</app-shell>export class ForbiddenPage {}<import from="../components/app-shell"></import>
<app-shell>
<h1 au-slot="title">Access denied</h1>
<a au-slot="actions" load="../dashboard">Back to Dashboard</a>
<p>You do not have permission to view that page.</p>
</app-shell>import { route } from '@aurelia/router';
import { AdminPage } from './pages/admin-page';
import { DashboardPage } from './pages/dashboard-page';
import { ForbiddenPage } from './pages/forbidden-page';
import { ProjectsPage } from './pages/projects-page';
@route({
routes: [
{ path: ['', 'dashboard'], component: DashboardPage, title: 'Dashboard' },
{ path: 'projects', component: ProjectsPage, title: 'Projects' },
{
path: 'admin',
component: AdminPage,
title: 'Admin',
data: {
roles: ['admin'],
fallbackRoute: 'forbidden'
}
},
{ path: 'forbidden', component: ForbiddenPage, title: 'Access denied' }
]
})
export class MyApp {}import { resolve } from '@aurelia/kernel';
import { IAuthService, User } from '../services/auth-service';
export class AuthStatus {
private readonly auth = resolve(IAuthService);
user: User | null = this.auth.getCurrentUser();
toggleAdmin(): void {
this.auth.toggleRole('admin');
this.user = this.auth.getCurrentUser();
}
}<section class="auth-status">
<p if.bind="user">
Signed in as ${user.name}. Roles: ${user.roles.join(', ')}
</p>
<p if.bind="!user">Signed out.</p>
<button click.trigger="toggleAdmin()">Toggle Admin Role</button>
</section><import from="../components/app-shell"></import>
<import from="../components/auth-status"></import>
<app-shell>
<h1 au-slot="title">Dashboard</h1>
<a au-slot="actions" load="../projects">View Projects</a>
<p>Welcome to Project Pulse. Use the Projects page to manage tasks.</p>
<auth-status></auth-status>
</app-shell><nav class="main-nav">
<a load="dashboard">Dashboard</a>
<a load="projects">Projects</a>
<a load="admin">Admin</a>
</nav>
<au-viewport></au-viewport><svg width="200" height="200">
<!-- Bind to standard SVG attributes -->
<circle cx.bind="circleX"
cy.bind="circleY"
r.bind="radius"
fill.bind="fillColor" />
<!-- Use interpolation for transform -->
<rect x="10" y="10"
width.bind="rectWidth"
height.bind="rectHeight"
transform="rotate(${rotation} 50 50)" />
</svg>export class SvgDemo {
circleX = 100;
circleY = 100;
radius = 50;
fillColor = '#3498db';
rectWidth = 80;
rectHeight = 60;
rotation = 45;
}<svg width="400" height="300" class="bar-chart">
<!-- Y-axis -->
<line x1="50" y1="10" x2="50" y2="250" stroke="#333" />
<!-- X-axis -->
<line x1="50" y1="250" x2="390" y2="250" stroke="#333" />
<!-- Dynamic bars -->
<g repeat.for="item of chartData; let i = $index">
<rect x.bind="60 + i * 70"
y.bind="250 - item.value * 2"
width="50"
height.bind="item.value * 2"
fill.bind="item.color" />
<text x.bind="85 + i * 70"
y="270"
text-anchor="middle"
font-size="12">
${item.label}
</text>
</g>
</svg>export class BarChart {
chartData = [
{ label: 'Jan', value: 65, color: '#3498db' },
{ label: 'Feb', value: 89, color: '#2ecc71' },
{ label: 'Mar', value: 72, color: '#e74c3c' },
{ label: 'Apr', value: 95, color: '#9b59b6' }
];
}<svg width="300" height="200">
<path d.bind="pathData"
fill="none"
stroke.bind="strokeColor"
stroke-width.bind="strokeWidth" />
</svg>export class PathDemo {
strokeColor = '#e74c3c';
strokeWidth = 2;
get pathData(): string {
return `M 10 80 Q 95 10 180 80 T 350 80`;
}
}<svg width="400" height="300">
<circle repeat.for="circle of circles"
cx.bind="circle.x"
cy.bind="circle.y"
r.bind="circle.r"
fill.bind="circle.color"
class.bind="circle.selected ? 'selected' : ''"
click.trigger="selectCircle(circle)"
mouseenter.trigger="highlightCircle(circle)"
mouseleave.trigger="unhighlightCircle(circle)"
style="cursor: pointer;" />
</svg><svg width="200" height="200">
<rect x="50" y="50"
width="100" height="100"
class.bind="isActive ? 'shape-active' : 'shape-inactive'" />
</svg>.shape-active {
fill: #2ecc71;
stroke: #27ae60;
stroke-width: 3;
}
.shape-inactive {
fill: #bdc3c7;
stroke: #95a5a6;
stroke-width: 1;
}<svg width="200" height="200">
<defs>
<linearGradient id="dynamicGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color.bind="startColor" />
<stop offset="100%" stop-color.bind="endColor" />
</linearGradient>
</defs>
<rect x="10" y="10" width="180" height="180" fill="url(#dynamicGradient)" />
</svg>function greet() {
return `Hello, ${this.name}!`;
}
const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };
console.log(greet.call(person1)); // "Hello, Alice!"
console.log(greet.call(person2)); // "Hello, Bob!"┌─────────────────────────┐
│ Scope │
│ │
│ ┌─────────────────┐ │
│ │ bindingContext │ │ ← Your component's data
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ overrideContext │ │ ← Special contextual values
│ └─────────────────┘ │
│ │
│ parent ────────────────┼─→ Points to parent scope
│ isBoundary: boolean │ ← Component boundary marker
└─────────────────────────┘export class MyComponent {
message = 'Hello, World!';
count = 42;
greet() {
return `The count is ${this.count}`;
}
}<div repeat.for="item of items">
${$index}: ${item.name} <!-- $index comes from override context -->
</div><!-- parent-component.html -->
<div>
<child-component></child-component>
</div>
<!-- child-component.html -->
<p>Parent's title: ${$parent.title}</p><!-- This works within the same component -->
<div with.bind="user">
${name} <!-- Finds user.name, then falls back to component properties -->
</div>
<!-- To access parent component data, use $parent -->
<child-component>
<!-- Inside child, to get parent's data: -->
${$parent.parentProperty}
</child-component><!-- my-card.html (defines slots) -->
<div class="card">
<slot></slot>
</div>
<!-- Usage with projected content -->
<my-card>
<p>Card title: ${$host.title}</p> <!-- Accesses my-card's title -->
</my-card>// In your component, expose the scope for debugging
export class DebugComponent {
created() {
(window as any).debugScope = this.$controller.scope;
}
}// Explore the scope structure
debugScope.bindingContext // Your component instance
debugScope.overrideContext // Special contextual values
debugScope.parent // Parent scope (if any)
// Check if it's a component boundary
debugScope.isBoundary<!-- Good: Explicit about crossing boundaries -->
<div>${$parent.parentProperty}</div>
<!-- Confusing: Relies on scope traversal -->
<div>${parentProperty}</div>public binding(): void {
// Good: Set before binding establishment
this.$controller.scope.overrideContext.customValue = 'something';
}import Aurelia from 'aurelia';
import { RouterConfiguration } from '@aurelia/router';
import { MyRootComponent } from './my-root-component';
// Simplest startup - hosts to <my-root-component> element, or <body> if not found
Aurelia.app(MyRootComponent).start();
// Register additional features before startup
Aurelia
.register(
RouterConfiguration.customize({ useUrlFragmentHash: false })
)
.app(MyRootComponent)
.start();
// Specify a custom host element
Aurelia
.register(
RouterConfiguration.customize({ useUrlFragmentHash: false })
)
.app({
component: MyRootComponent,
host: document.querySelector('my-start-tag')
})
.start();
// Async startup pattern (recommended)
const app = Aurelia
.register(
RouterConfiguration.customize({ useUrlFragmentHash: false })
)
.app(MyRootComponent);
await app.start();import { Aurelia, StandardConfiguration } from '@aurelia/runtime-html';
import { RouterConfiguration } from '@aurelia/router';
import { LoggerConfiguration, LogLevel } from '@aurelia/kernel';
import { ShellComponent } from './shell';
// Create Aurelia instance with explicit configuration
const au = new Aurelia();
au.register(
StandardConfiguration, // Essential runtime configuration
RouterConfiguration.customize({ useUrlFragmentHash: false }),
LoggerConfiguration.create({ level: LogLevel.debug })
);
au.app({
host: document.querySelector('body'),
component: ShellComponent
});
// Always await start() for proper error handling
await au.start();import Aurelia from 'aurelia';
import { CardCustomElement } from './components/card';
// Quick startup
Aurelia
.register(CardCustomElement) // No type casting needed
.app(MyRootComponent)
.start();
// Verbose startup
const au = new Aurelia();
au.register(
StandardConfiguration,
CardCustomElement
);
au.app({ host: document.body, component: MyRootComponent });
await au.start();export { CardCustomElement } from './card';
export { CollapseCustomElement } from './collapse';
export { ModalCustomElement } from './modal';import Aurelia from 'aurelia';
import * as GlobalComponents from './components';
// Register all exported components at once
Aurelia
.register(GlobalComponents)
.app(MyRootComponent)
.start();import Aurelia from 'aurelia';
import { MyValueConverter } from './converters/my-value-converter';
import { MyBindingBehavior } from './behaviors/my-binding-behavior';
import { MyCustomAttribute } from './attributes/my-custom-attribute';
Aurelia
.register(
MyValueConverter,
MyBindingBehavior,
MyCustomAttribute
)
.app(MyRootComponent)
.start();import { Registration } from '@aurelia/kernel';
import { MyService, IMyService } from './services/my-service';
Aurelia
.register(
Registration.singleton(IMyService, MyService)
)
.app(MyRootComponent)
.start();import Aurelia, { LoggerConfiguration, LogLevel } from 'aurelia';
const isProduction = process.env.NODE_ENV === 'production';
Aurelia
.register(
LoggerConfiguration.create({
level: isProduction ? LogLevel.warn : LogLevel.debug
})
)
.app(MyRootComponent)
.start();const enhanceRoot = await Aurelia.enhance({
host: document.querySelector('#existing-content'),
component: { message: 'Hello from enhanced content!' }
});valuenodeObserverLocator.useConfig({
'FAST-TEXT-FIELD': {
value: { events: ['change'] }
},
'ION-INPUT': {
value: { events: ['change'] }
},
'PAPER-INPUT': {
value: { events: ['change'] }
}
})<input value.bind="message"><fast-text-field value.bind="message">
<ion-input value.bind="message">
<paper-input value.bind="message"><fast-text-field value.two-way="message">
<ion-input value.two-way="message">
<paper-input value.two-way="message">import { IAttrMapper, resolve } from 'aurelia';
export class MyCustomElement {
private attrMapper = resolve(IAttrMapper);
constructor() {
// do something with this.attrMapper
}
}attrMapper.useTwoWay((element, attrName) => {
switch (element.tagName) {
case 'FAST-TEXT-FIELD':
case 'ION-INPUT':
case 'PAPER-INPUT':
return attrName === 'value';
default:
return false;
}
});import { resolve } from 'aurelia';
import { INodeObserverLocator } from '@aurelia/runtime';
export class MyCustomElement {
private nodeObserverLocator = resolve(INodeObserverLocator);
constructor() {
// do something with this.nodeObserverLocator
}
}nodeObserverLocator.useConfig('FAST-TEXT-FIELD', 'value', { events: ['change' ] });nodeObserverLocator.useConfig('ION-INPUT', 'value', { events: ['change' ] });
nodeObserverLocator.useConfig('PAPER-INPUT', 'value', { events: ['change' ] });import Aurelia, { AppTask, IAttrMapper } from 'aurelia';
import { INodeObserverLocator } from '@aurelia/runtime';
Aurelia
.register(
AppTask.creating(IAttrMapper, attrMapper => {
attrMapper.useTwoWay((el, attrName) => {
switch (el.tagName) {
case 'FAST-TEXT-FIELD':
case 'FAST-TEXT-AREA':
case 'FAST-SLIDER':
return attrName === 'value';
default:
return false;
}
});
}),
AppTask.creating(INodeObserverLocator, nodeObserverLocator => {
nodeObserverLocator.useConfig({
'FAST-TEXT-FIELD': {
value: { events: ['change'] }
},
'FAST-TEXT-AREA': {
value: { events: ['change'] }
},
'FAST-SLIDER': {
value: { events: ['change'] }
}
});
})
)
.app(class MyApp {})
.start();<fast-text-field value.bind="message"></fast-text-field>
<fast-text-area value.bind="description"></fast-text-area>
<fast-slider value.bind="fontSize"></fast-slider>${message} syntax creates a binding between your view-model property and the template, automatically updating the UI when the property changes.export class MyComponent {
message = 'Hello from Aurelia!';
}<h1>${message}</h1>import { customElement } from 'aurelia';
@customElement('hello-world')
export class HelloWorld {
name = 'World';
}<h1>Hello, ${name}!</h1><import from="./hello-world"></import>
<div>
<hello-world></hello-world>
</div>import Aurelia from 'aurelia';
import { MyApp } from './my-app';
import { HelloWorld } from './hello-world';
Aurelia
.register(HelloWorld) // Register globally
.app(MyApp)
.start();import { bindable } from 'aurelia';
export class UserCard {
@bindable name: string;
@bindable email: string;
@bindable avatar: string;
}<div class="user-card">
<img src.bind="avatar" alt="Avatar">
<h3>${name}</h3>
<p>${email}</p>
</div><user-card name.bind="user.name" email.bind="user.email" avatar.bind="user.avatar"></user-card>export class MyComponent {
created() {
// Component instance created
}
binding() {
// Data binding starts
}
bound() {
// Data binding completed
}
attaching() {
// Before DOM attachment
}
attached() {
// Component attached to DOM
}
detaching() {
// Before DOM removal
}
unbinding() {
// Data binding being removed
}
}import { resolve } from '@aurelia/kernel';
import { IRouter } from '@aurelia/router';
export class UserListPage {
private router = resolve(IRouter);
users: User[] = [];
isLoading = false;
async binding() {
this.isLoading = true;
try {
const response = await fetch('/api/users');
this.users = await response.json();
} finally {
this.isLoading = false;
}
}
viewUser(user: User) {
this.router.load(`/users/${user.id}`);
}
deleteUser(user: User) {
// Handle deletion
}
}<div class="page">
<h1>Users</h1>
<loading-spinner if.bind="isLoading"></loading-spinner>
<user-list
users.bind="users"
on-view.bind="(user) => viewUser(user)"
on-delete.bind="(user) => deleteUser(user)">
</user-list>
</div>import { bindable } from 'aurelia';
export class UserList {
@bindable users: User[];
@bindable onView: (user: User) => void;
@bindable onDelete: (user: User) => void;
}<div class="user-list">
<div repeat.for="user of users" class="user-card">
<h3>${user.name}</h3>
<p>${user.email}</p>
<button click.trigger="onView(user)">View</button>
<button click.trigger="onDelete(user)">Delete</button>
</div>
</div>export class Card {
@bindable title: string;
@bindable actions: boolean = false;
}<div class="card">
<div class="card-header">
<h2>${title}</h2>
</div>
<div class="card-body">
<slot></slot> <!-- Main content goes here -->
</div>
<div class="card-footer" if.bind="actions">
<slot name="actions"></slot> <!-- Named slot for actions -->
</div>
</div><card title="User Profile" actions.bind="true">
<!-- Default slot content -->
<p>Name: ${user.name}</p>
<p>Email: ${user.email}</p>
<!-- Named slot content -->
<button slot="actions" click.trigger="edit()">Edit</button>
<button slot="actions" click.trigger="delete()">Delete</button>
</card>import { bindable, BindingMode } from 'aurelia';
export class FormInput {
@bindable label: string;
@bindable({ mode: BindingMode.twoWay }) value: string;
@bindable type: string = 'text';
@bindable required: boolean = false;
@bindable error: string;
}<div class="form-group">
<label>
${label}
<span if.bind="required" class="required">*</span>
</label>
<input
type.bind="type"
value.bind="value"
class="form-control ${error ? 'is-invalid' : ''}">
<div class="error-message" if.bind="error">
${error}
</div>
</div>export class RegistrationForm {
email: string = '';
password: string = '';
emailError: string;
validateEmail() {
this.emailError = this.email.includes('@') ? '' : 'Invalid email';
}
}<form-input
label="Email"
value.bind="email"
type="email"
required.bind="true"
error.bind="emailError"
blur.trigger="validateEmail()">
</form-input>
<form-input
label="Password"
value.bind="password"
type="password"
required.bind="true">
</form-input>import { bindable } from 'aurelia';
export class Accordion {
@bindable items: AccordionItem[];
expandedIndex: number | null = null;
toggle(index: number) {
this.expandedIndex = this.expandedIndex === index ? null : index;
}
isExpanded(index: number) {
return this.expandedIndex === index;
}
}<div class="accordion">
<div repeat.for="item of items" class="accordion-item">
<button
class="accordion-header ${isExpanded($index) ? 'expanded' : ''}"
click.trigger="toggle($index)">
${item.title}
</button>
<div class="accordion-content" show.bind="isExpanded($index)">
${item.content}
</div>
</div>
</div>import { bindable } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { IEventAggregator } from '@aurelia/kernel';
export class SearchBox {
@bindable placeholder: string = 'Search...';
@bindable onSearch: (query: string) => void;
private ea = resolve(IEventAggregator);
query: string = '';
handleSearch() {
// Option 1: Callback binding
if (this.onSearch) {
this.onSearch(this.query);
}
// Option 2: Event aggregator (for loosely coupled components)
this.ea.publish('search:query', this.query);
}
handleClear() {
this.query = '';
this.handleSearch();
}
}<div class="search-box">
<input
value.bind="query"
placeholder.bind="placeholder"
keyup.trigger="handleSearch() & debounce:300">
<button click.trigger="handleClear()" if.bind="query">
Clear
</button>
</div><!-- Using callback -->
<search-box on-search.bind="(query) => performSearch(query)"></search-box>
<!-- Or listen via event aggregator -->export class ProductCatalog {
private ea = resolve(IEventAggregator);
binding() {
this.ea.subscribe('search:query', query => {
this.performSearch(query);
});
}
}<p>Quick maths: ${2 + 2}</p>
<!-- Outputs "Quick maths: 4" -->export class MyApp {
adder(val1: number, val2: number): number {
return parseInt(val1) + parseInt(val2);
}
}<p>Behold mathematics, 6 + 1 = ${adder(6, 1)}</p>
<!-- Outputs "Behold mathematics, 6 + 1 = 7" --><p>${isTrue ? 'True' : 'False'}</p>export class MyApp {
items = [
{ name: 'Apple', price: 1.50, category: 'fruit' },
{ name: 'Banana', price: 0.80, category: 'fruit' },
{ name: 'Carrot', price: 0.90, category: 'vegetable' }
];
}export class MyApp {
user = {
profile: {
personal: { firstName: 'John', lastName: 'Doe' },
settings: { theme: 'dark', notifications: true }
}
};
}<p>Status: ${isLoggedIn && user ? 'Authenticated' : 'Guest'}</p>
<p>Display: ${showDetails || showSummary ? 'Visible' : 'Hidden'}</p>
<p>Count: ${count || 0}</p>
<p>Message: ${message?.trim() || 'No message'}</p><p>User Name: ${user?.name ?? 'Anonymous'}</p>export class MyApp {
content = document.createElement('button');
constructor() {
this.content.textContent = 'Click me!';
this.content.addEventListener('click', () => {
alert('Button clicked!');
});
}
}<div>${content}</div>export class MyApp {
content = (() => {
const tpl = document.createElement('template');
tpl.innerHTML = '<button>Parsed Button</button>';
return tpl.content.firstElementChild as HTMLElement;
})();
}<div>${content}</div>export class MyApp {
// ✅ Safe: Creating known elements
createSafeButton() {
const button = document.createElement('button');
button.textContent = 'Safe Button'; // textContent escapes content
button.className = 'safe-class';
return button;
}
// ❌ Dangerous: Using innerHTML with user input
createUnsafeElement(userInput: string) {
const div = document.createElement('div');
div.innerHTML = userInput; // Can execute scripts!
return div;
}
// ✅ Better: Sanitize user input or use textContent
createSafeElement(userInput: string) {
const div = document.createElement('div');
div.textContent = userInput; // Escapes all HTML
return div;
}
}export class MyApp {
elements: HTMLElement[] = [];
addElement() {
const newElement = document.createElement('span');
newElement.textContent = `Element ${this.elements.length + 1}`;
newElement.style.color = 'blue';
this.elements.push(newElement);
}
}<button click.trigger="addElement()">Add Element</button>
<div repeat.for="element of elements">${element}</div><p>${items.filter(i => i.active).map(i => i.name.toUpperCase()).join(', ')}</p>export class MyApp {
get activeItemNames() {
return this.items
.filter(i => i.active)
.map(i => i.name.toUpperCase())
.join(', ');
}
}export class MyApp {
private _cachedResult: string = '';
private _lastArrayLength: number = 0;
get expensiveArrayComputation() {
if (this.largeArray.length !== this._lastArrayLength) {
this._cachedResult = this.largeArray
.filter(/* complex filter */)
.reduce(/* expensive operation */, '');
this._lastArrayLength = this.largeArray.length;
}
return this._cachedResult;
}
}export class MyApp {
elements: HTMLElement[] = [];
detaching() {
// Clean up event listeners and references
this.elements.forEach(el => {
el.removeEventListener('click', this.handleClick);
});
this.elements = [];
}
}export class MyApp {
name: string | null = null;
data: any = undefined;
}<p>${user.profile.name}</p> <!-- Error if user or profile is null -->
<p>${items[selectedIndex].title}</p> <!-- Error if index out of bounds -->
<p>${calculateTotal()}</p> <!-- Error if method throws --><p>${user?.profile?.name ?? 'Anonymous'}</p>
<p>${items[selectedIndex]?.title ?? 'No item selected'}</p>
<p>${safeCalculateTotal()}</p>export class MyApp {
number = 42;
boolean = true;
array = [1, 2, 3];
object = { name: 'test' };
}export class MyApp {
nullElement: HTMLElement | null = null;
detachedElement = document.createElement('div');
constructor() {
this.detachedElement.textContent = 'Detached';
// Element not in DOM yet
}
}export class MyApp {
items = [];
constructor() {
this.items.push({ name: 'Item 1' }, { name: 'Item 2' });
}
addItem() {
this.items.push({ name: `Item ${this.items.length + 1}` });
}
}<ul>
<li repeat.for="item of items">${item.name}</li>
</ul>
<button click.trigger="addItem()">Add Item</button>export type Task = {
id: string;
title: string;
done: boolean;
};
export type Project = {
id: string;
name: string;
tasks: Task[];
};import { Project } from './models';
export const PROJECTS: Project[] = [
{
id: 'alpha',
name: 'Onboarding',
tasks: [
{ id: 'alpha-1', title: 'Create welcome pack', done: false },
{ id: 'alpha-2', title: 'Schedule kickoff', done: false }
]
},
{
id: 'beta',
name: 'Release prep',
tasks: [
{ id: 'beta-1', title: 'Finalize changelog', done: true },
{ id: 'beta-2', title: 'QA smoke test', done: false }
]
}
];import { bindable } from 'aurelia';
import { Project } from '../models';
export class ProjectCard {
@bindable project!: Project;
@bindable onRemove?: (project: Project) => void;
remove(): void {
this.onRemove?.(this.project);
}
}<import from="./task-list"></import>
<section class="project-card">
<header class="project-card__header">
<h3>${project.name}</h3>
<span class="project-card__count">
${project.tasks.length} tasks
</span>
</header>
<task-list tasks.bind="project.tasks" project-id.bind="project.id"></task-list>
<footer class="project-card__footer">
<a load="route: ../project-detail; params.bind: { id: project.id }">
Open project
</a>
<button class="project-card__remove" click.trigger="remove()">
Remove
</button>
</footer>
</section>import { bindable } from 'aurelia';
import { Task } from '../models';
export class TaskList {
@bindable tasks: Task[] = [];
@bindable projectId = '';
}<import from="./task-item"></import>
<ul class="task-list">
<li repeat.for="task of tasks">
<task-item task.bind="task" project-id.bind="projectId"></task-item>
</li>
</ul>import { IEventAggregator, resolve } from '@aurelia/kernel';
import { bindable } from 'aurelia';
import { Task } from '../models';
export class TaskItem {
@bindable task!: Task;
@bindable projectId = '';
private readonly ea = resolve(IEventAggregator);
notifyToggle(): void {
this.ea.publish('task:toggled', {
projectId: this.projectId,
taskId: this.task.id,
done: this.task.done
});
}
}<label class="task-item">
<input type="checkbox" checked.bind="task.done" change.trigger="notifyToggle()" />
<span class.bind="task.done ? 'task-item__done' : ''">
${task.title}
</span>
</label>import { IEventAggregator, IDisposable, resolve } from '@aurelia/kernel';
import { IRouter, IRouteViewModel, Params, RouteNode } from '@aurelia/router';
import { observable } from 'aurelia';
import { Project } from '../models';
import { PROJECTS } from '../project-data';
export class ProjectsOverviewPage implements IRouteViewModel {
@observable searchQuery = '';
projects: Project[] = structuredClone(PROJECTS);
filteredProjects: Project[] = this.projects;
recentActivity: string[] = [];
newProjectName = '';
private readonly ea = resolve(IEventAggregator);
private readonly router = resolve(IRouter);
private subscription?: IDisposable;
loading(_params: Params, next: RouteNode): void {
const query = next.queryParams.get('q');
this.searchQuery = query ?? '';
this.applyFilter();
}
bound(): void {
this.subscription = this.ea.subscribe('task:toggled', ({ projectId, taskId, done }) => {
const project = this.projects.find(item => item.id === projectId);
const task = project?.tasks.find(item => item.id === taskId);
if (!project || !task) return;
const status = done ? 'completed' : 'reopened';
this.recentActivity.unshift(`${project.name}: ${task.title} ${status}`);
this.recentActivity = this.recentActivity.slice(0, 5);
});
}
unbinding(): void {
this.subscription?.dispose();
}
searchQueryChanged(): void {
this.applyFilter();
}
clearSearch(): void {
this.searchQuery = '';
this.applyFilter();
this.syncQueryToUrl();
}
syncQueryToUrl(): void {
void this.router.load('overview', {
context: this,
queryParams: this.searchQuery ? { q: this.searchQuery } : {}
});
}
handleNewProjectKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.addProject();
}
}
addProject(): void {
const name = this.newProjectName.trim();
if (!name) return;
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
this.projects = [
...this.projects,
{ id, name, tasks: [] }
];
this.newProjectName = '';
this.applyFilter();
}
removeProject(project: Project): void {
this.projects = this.projects.filter(item => item !== project);
this.applyFilter();
}
private applyFilter(): void {
const term = this.searchQuery.trim().toLowerCase();
this.filteredProjects = term
? this.projects.filter(project => project.name.toLowerCase().includes(term))
: this.projects;
}
}<import from="../components/project-card"></import>
<section class="toolbar">
<input
value.bind="searchQuery"
placeholder="Search projects" />
<button if.bind="searchQuery" click.trigger="clearSearch()">
Clear
</button>
<button if.bind="searchQuery" click.trigger="syncQueryToUrl()">
Share Filter
</button>
</section>
<section class="project-create">
<input
value.bind="newProjectName"
placeholder="New project name"
keydown.trigger="handleNewProjectKeydown($event)" />
<button disabled.bind="!newProjectName.trim()" click.trigger="addProject()">
Add project
</button>
</section>
<div class="project-grid">
<project-card
repeat.for="project of filteredProjects"
project.bind="project"
on-remove.bind="(project) => removeProject(project)">
</project-card>
</div>
<aside class="activity" if.bind="recentActivity.length">
<h2>Recent activity</h2>
<ul>
<li repeat.for="entry of recentActivity">${entry}</li>
</ul>
</aside>import { PROJECTS } from '../project-data';
export class ProjectsActivityPage {
totalProjects = PROJECTS.length;
get totalTasks(): number {
return PROJECTS.reduce((total, project) => total + project.tasks.length, 0);
}
get completedTasks(): number {
return PROJECTS.reduce(
(total, project) => total + project.tasks.filter(task => task.done).length,
0
);
}
}<section class="activity-summary">
<h2>Activity Summary</h2>
<p>Total projects: ${totalProjects}</p>
<p>Tasks completed: ${completedTasks} / ${totalTasks}</p>
</section><div>
<h3>Promise Example 1</h3>
<div promise.bind="promise1">
<template pending>Promise 1: Loading...</template>
<template then="data">Promise 1: Resolved with: ${data}</template>
<template catch="err">Promise 1: Rejected with error: ${err.message}</template>
</div>
</div>
<div>
<h3>Promise Example 2 (No data in 'then' state)</h3>
<div promise.bind="promise2">
<template pending>Promise 2: Waiting...</template>
<template then>Promise 2: Successfully Resolved!</template>
<template catch>Promise 2: An error occurred!</template>
</div>
</div>export class MyApp {
promise1: Promise<string>;
promise2: Promise<void>;
constructor() {
this.promise1 = this.createDelayedPromise('Promise 1 Data', 2000, true); // Resolves after 2 seconds
this.promise2 = this.createDelayedPromise(undefined, 3000, false); // Rejects after 3 seconds
}
createDelayedPromise(data: any, delay: number, shouldResolve: boolean): Promise<any> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldResolve) {
resolve(data);
} else {
reject(new Error('Promise rejected after delay'));
}
}, delay);
});
}
}<let adviceIndex.bind="0"></let>
<div promise.bind="fetchAdvice(adviceIndex)">
<span pending>Fetching advice...</span>
<span then="adviceData">
Advice ID: ${adviceData.slip.id}<br>
"${adviceData.slip.advice}"
<button click.trigger="adviceIndex = adviceIndex + 1">Get New Advice</button>
</span>
<span catch="fetchError">
Failed to get advice. Error: ${fetchError}
<button click.trigger="adviceIndex = adviceIndex + 1">Try Again</button>
</span>
</div>export class MyApp {
adviceIndex = 0; // Initialize adviceIndex
fetchAdvice(index: number): Promise<any> {
// 'index' parameter ensures function re-execution on parameter change
console.log(`Fetching advice, attempt: ${index}`);
return fetch("https://api.adviceslip.com/advice", {
cache: 'no-store' // Prevents caching for example clarity
})
.then(response => response.ok
? response.json()
: Promise.reject(new Error(`HTTP error! status: ${response.status}`))
)
.catch(error => {
console.error("Fetch error:", error);
throw error; // Re-throw to be caught by the promise template
});
}
}<div promise.bind="myPromise">
<template pending>Loading data...</template>
<template then="data">Data loaded: ${data}</template>
<template catch="error">Error: ${error.message}</template>
</div><div promise.bind="userPromise">
<template then="userData">
<user-profile user-data.bind="userData"></user-profile>
<p>User ID within promise scope: ${userData.id}</p>
<!-- Accessing parent scope (if needed, though generally discouraged) -->
<!-- <p>Some parent property: ${$parent.someProperty}</p> -->
</template>
<template catch="userError">
<error-display error-message.bind="userError.message"></error-display>
</template>
</div><div promise.bind="initialFetchPromise">
<template pending>Fetching initial data...</template>
<template then="initialResponse" promise.bind="initialResponse.json()">
<template then="jsonData">
Data received and deserialized: ${jsonData.name}
</template>
<template catch="jsonError">
Error deserializing JSON: ${jsonError.message}
</template>
</template>
<template catch="fetchError">
Error fetching initial data: ${fetchError.message}
</template>
</div><let promiseItems.bind="[[42, true], ['error-string', false], ['success-string', true]]"></let>
<ul>
<template repeat.for="item of promiseItems">
<li>
<template promise.bind="createPromise(item[0], item[1])">
<let itemData.bind="null"></let> <let itemError.bind="null"></let>
<span pending>Processing item...</span>
<span then="itemData">Item processed successfully: ${itemData}</span>
<span catch="itemError">Item processing failed: ${itemError.message}</span>
</template>
</li>
</template>
</ul>export class MyApp {
promiseItems: any[][]; // Defined in HTML using <let>
createPromise(value: any, shouldResolve: boolean): Promise<any> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldResolve) {
resolve(value);
} else {
reject(new Error(`Promise rejected for value: ${value}`));
}
}, 1000); // Simulate async processing
});
}
}export class ContactForm {
formData = {
name: '',
email: '',
message: ''
};
isSubmitting = false;
successMessage = '';
errorMessage = '';
async handleSubmit() {
this.isSubmitting = true;
this.errorMessage = '';
this.successMessage = '';
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
});
if (!response.ok) {
throw new Error('Submission failed');
}
this.successMessage = 'Form submitted successfully!';
this.resetForm();
} catch (error) {
this.errorMessage = 'Failed to submit form. Please try again.';
} finally {
this.isSubmitting = false;
}
}
resetForm() {
this.formData = { name: '', email: '', message: '' };
}
}<form submit.trigger="handleSubmit()">
<div class="form-group">
<label>Name</label>
<input value.bind="formData.name" required />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" value.bind="formData.email" required />
</div>
<div class="form-group">
<label>Message</label>
<textarea value.bind="formData.message" required></textarea>
</div>
<div if.bind="successMessage" class="alert alert-success">
${successMessage}
</div>
<div if.bind="errorMessage" class="alert alert-error">
${errorMessage}
</div>
<button type="submit" disabled.bind="isSubmitting">
${isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form><!-- Method 1: Use submit.trigger (recommended) -->
<form submit.trigger="handleSubmit()">
<!-- form fields -->
</form>
<!-- Method 2: Prevent default in handler -->
<form submit.trigger="handleSubmit($event)">
<!-- form fields -->
</form>handleSubmit(event?: Event) {
event?.preventDefault();
// your logic
}import { resolve } from '@aurelia/kernel';
import { IHttpClient } from '@aurelia/fetch-client';
export class UserService {
private readonly http = resolve(IHttpClient);
private readonly cache: User[] = [];
async getUsers(): Promise<User[]> {
const response = await this.http.fetch('/api/users');
const payload = await response.json();
this.cache.splice(0, this.cache.length, ...payload);
return this.cache;
}
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await this.http.fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData),
headers: { 'Content-Type': 'application/json' },
});
const user = await response.json();
this.cache.push(user);
return user;
}
}Learn the basics of Aurelia by building an interactive Hello, World! application from scratch
export class ValidatedForm {
formData = { name: '', email: '' };
get isValid(): boolean {
return this.formData.name.length > 0 &&
this.formData.email.includes('@');
}
handleSubmit() {
if (!this.isValid) {
alert('Please fill out all required fields');
return;
}
// Submit form
}
}<form submit.trigger="handleSubmit()">
<!-- fields -->
<button type="submit" disabled.bind="!isValid">Submit</button>
</form>interface SubmissionState {
isSubmitting: boolean;
success: boolean;
error: string | null;
attempts: number;
}
export class StatefulForm {
formData = { /* ... */ };
state: SubmissionState = {
isSubmitting: false,
success: false,
error: null,
attempts: 0
};
get canSubmit(): boolean {
return !this.state.isSubmitting && this.state.attempts < 3;
}
async handleSubmit() {
if (!this.canSubmit) return;
this.state.isSubmitting = true;
this.state.error = null;
this.state.success = false;
try {
await this.submitData();
this.state.success = true;
setTimeout(() => this.resetForm(), 2000);
} catch (error) {
this.state.error = error.message;
this.state.attempts++;
} finally {
this.state.isSubmitting = false;
}
}
private async submitData() {
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();
}
resetForm() {
this.formData = { /* reset */ };
this.state = {
isSubmitting: false,
success: false,
error: null,
attempts: 0
};
}
}export class OptimisticForm {
items: Item[] = [];
optimisticItem: Item | null = null;
async addItem(item: Item) {
// Add optimistically
this.optimisticItem = { ...item, id: 'temp-' + Date.now() };
this.items.push(this.optimisticItem);
try {
const result = await this.saveItem(item);
// Replace optimistic item with real one
const index = this.items.indexOf(this.optimisticItem);
this.items[index] = result;
this.optimisticItem = null;
} catch (error) {
// Remove optimistic item on error
this.items = this.items.filter(i => i !== this.optimisticItem);
this.optimisticItem = null;
alert('Failed to add item');
}
}
}export class AutoSaveForm {
formData = { title: '', content: '' };
saveStatus: 'saved' | 'saving' | 'unsaved' = 'saved';
saveTimer: any = null;
formDataChanged() {
this.saveStatus = 'unsaved';
// Clear existing timer
clearTimeout(this.saveTimer);
// Set new timer for auto-save
this.saveTimer = setTimeout(() => {
this.autoSave();
}, 2000);
}
async autoSave() {
this.saveStatus = 'saving';
try {
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
});
this.saveStatus = 'saved';
} catch (error) {
this.saveStatus = 'unsaved';
}
}
detaching() {
clearTimeout(this.saveTimer);
}
}<form>
<input value.bind="formData.title" input.trigger="formDataChanged()" />
<textarea value.bind="formData.content" input.trigger="formDataChanged()"></textarea>
<span class="save-status">
<span if.bind="saveStatus === 'saved'">✓ Saved</span>
<span if.bind="saveStatus === 'saving'">Saving...</span>
<span if.bind="saveStatus === 'unsaved'">Unsaved changes</span>
</span>
</form>export class RateLimitedForm {
lastSubmission: Date | null = null;
cooldownMs = 30000; // 30 seconds
get canSubmit(): boolean {
if (!this.lastSubmission) return true;
const timeSince = Date.now() - this.lastSubmission.getTime();
return timeSince > this.cooldownMs;
}
get cooldownRemaining(): number {
if (!this.lastSubmission) return 0;
const timeSince = Date.now() - this.lastSubmission.getTime();
const remaining = this.cooldownMs - timeSince;
return Math.max(0, Math.ceil(remaining / 1000));
}
async handleSubmit() {
if (!this.canSubmit) {
alert(`Please wait ${this.cooldownRemaining} seconds before submitting again`);
return;
}
// Submit form
await this.submitData();
this.lastSubmission = new Date();
}
}<form submit.trigger="handleSubmit()">
<input value.bind="query" keydown.trigger:enter="handleSubmit()" />
</form>handleSubmit() {
if (!confirm('Are you sure you want to submit?')) {
return;
}
// Proceed with submission
}import { resolve } from '@aurelia/kernel';
import { IRouter } from '@aurelia/router';
export class FormWithRedirect {
private readonly router = resolve(IRouter);
async handleSubmit() {
await this.submitData();
this.router.load('/success');
}
}import { DI } from '@aurelia/kernel';
export interface IUserService {
getUsers(): Promise<User[]>;
createUser(userData: CreateUserRequest): Promise<User>;
}
export const IUserService = DI.createInterface<IUserService>('IUserService', x => x.singleton(UserService));import { resolve } from '@aurelia/kernel';
import { IUserService } from './user-service';
export class UserList {
private users: User[] = [];
private userService = resolve(IUserService);
async created() {
this.users = await this.userService.getUsers();
}
async addUser(userData: CreateUserRequest) {
const newUser = await this.userService.createUser(userData);
this.users.push(newUser);
}
}import { resolve } from '@aurelia/kernel';
import { IHttpClient } from '@aurelia/fetch-client';
import { ILogger } from '@aurelia/kernel';
export class UserService {
private http = resolve(IHttpClient);
private logger = resolve(ILogger);
async getUsers(): Promise<User[]> {
try {
const response = await this.http.fetch('/api/users');
return await response.json();
} catch (error) {
this.logger.error('Failed to fetch users', error);
throw error;
}
}
}// Singleton (default) - one instance per application
export const IUserService = DI.createInterface<IUserService>('IUserService', x => x.singleton(UserService));
export type IUserService = UserService;
// Transient - new instance every time
export const IEventLogger = DI.createInterface<IEventLogger>('IEventLogger', x => x.transient(EventLogger));
export type IEventLogger = EventLogger;export interface ApiConfig {
baseUrl: string;
timeout: number;
retries: number;
}
export const IApiConfig = DI.createInterface<ApiConfig>('IApiConfig');
// Register in main.ts
import Aurelia, { Registration } from 'aurelia';
import { IApiConfig } from './services/api-config';
Aurelia.register(
Registration.instance(IApiConfig, {
baseUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
})
);import { resolve } from '@aurelia/kernel';
import { IHttpClient } from '@aurelia/fetch-client';
import { IApiConfig } from './services/api-config';
export class ApiService {
private readonly config = resolve(IApiConfig);
private readonly http = resolve(IHttpClient);
constructor() {
this.http.configure((cfg) => {
cfg.baseUrl = this.config.baseUrl;
cfg.timeout = this.config.timeout;
});
}
}import { all, resolve } from '@aurelia/kernel';
export class MetricsPanel {
private sinks = resolve(all(IMetricSink));
attached() {
for (const sink of this.sinks) {
sink.flush();
}
}
}import { createResolver, resolve } from '@aurelia/kernel';
const newest = createResolver((key, handler, requestor) => {
const instances = requestor.getAll(key);
return instances[instances.length - 1];
});
export const newestLogger = newest(ILogger);
export class AuditTrail {
private readonly logger = resolve(newestLogger);
}// Test setup
const mockUserService = {
getUsers: () => Promise.resolve([{ id: 1, name: 'Test User' }]),
createUser: (data) => Promise.resolve({ id: 2, ...data })
};
const container = DI.createContainer();
container.register(Registration.instance(IUserService, mockUserService));
// Test your component with mocked dependencies
const component = container.get(UserList);import Aurelia, { TemplateCompilerHooks } from 'aurelia';
Aurelia
.register(TemplateCompilerHooks.define(class {
compiling(template: HTMLElement) {
template.querySelector('table')?.setAttribute(someAttribute, someValue);
}
}));import Aurelia, { templateCompilerHooks } from 'aurelia';
@templateCompilerHooks
class MyTableHook1 {
compiling(template) {...}
}
// paren ok too
@templateCompilerHooks()
class MyTableHook1 {
compiling(template) {...}
}
Aurelia.register(MyTableHook1);import Aurelia, { AppTask, ITemplateCompiler } from 'aurelia';
import { MyApp } from './my-app';
Aurelia
.register(AppTask.creating(ITemplateCompiler, compiler => compiler.debug = true))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
class FeatureFlagHook {
compiling(template: HTMLElement) {
const featureElements = template.querySelectorAll('[data-feature]');
for (const element of featureElements) {
const featureName = element.getAttribute('data-feature') ?? '';
if (!activeFeatureFlags[featureName]) {
element.remove();
}
}
}
}
const activeFeatureFlags: Record<string, boolean> = {
'new-ui': true,
'beta-feature': false
};
Aurelia.register(TemplateCompilerHooks.define(FeatureFlagHook))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
class FormFieldHook {
private fieldCounter = 0;
compiling(template: HTMLElement) {
const formFields = template.querySelectorAll('input, textarea, select');
for (const field of formFields) {
if (!field.hasAttribute('id')) {
const uniqueId = `form-field-${this.fieldCounter++}`;
field.setAttribute('id', uniqueId);
const label = template.querySelector(`label[for="${field.getAttribute('name')}"]`);
if (label) {
label.setAttribute('for', uniqueId);
}
}
}
}
}
Aurelia.register(TemplateCompilerHooks.define(FormFieldHook))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
class AriaRoleHook {
compiling(template: HTMLElement) {
const buttons = template.querySelectorAll('.btn');
for (const button of buttons) {
if (!button.hasAttribute('role')) {
button.setAttribute('role', 'button');
}
}
}
}
Aurelia.register(TemplateCompilerHooks.define(AriaRoleHook))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
class CSPHook {
compiling(template: HTMLElement) {
const elementsWithInlineStyles = template.querySelectorAll('[style]');
for (const element of elementsWithInlineStyles) {
console.warn(`Inline style removed from element for CSP compliance:`, element);
element.removeAttribute('style');
}
}
}
Aurelia.register(TemplateCompilerHooks.define(CSPHook))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
class LazyLoadingHook {
compiling(template: HTMLElement) {
const images = template.querySelectorAll('img:not([loading])');
for (const img of images) {
img.setAttribute('loading', 'lazy');
}
}
}
Aurelia.register(TemplateCompilerHooks.define(LazyLoadingHook))
.app(MyApp)
.start();import Aurelia, { TemplateCompilerHooks } from 'aurelia';
const userSelectedTheme = 'dark'; // For example, a dark theme
class ThemeClassHook {
private readonly currentTheme = userSelectedTheme;
compiling(template: HTMLElement) {
const rootElement = template.querySelector(':root');
if (rootElement) {
rootElement.classList.add(`theme-${this.currentTheme}`);
}
}
}
Aurelia.register(TemplateCompilerHooks.define(ThemeClassHook))
.app(MyApp)
.start();<user-card user.bind="user"></user-card><!-- Parent template -->
<user-card user.bind="user" on-delete.bind="handleDelete"></user-card>
// Child view-model
import { bindable } from '@aurelia/runtime-html';
export class UserCard {
@bindable() public onDelete: (user: User) => void;
deleteUser(): void {
this.onDelete?.(this.user);
}
}items = [
{ price: 10, qty: 2 },
{ price: 20, qty: 1 }
];
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
}npx makes aureliacd hello-world
npm startexport class MyApp {
message = 'Hello World!';
}<div class="message">${message}</div>export class HelloName {
name = 'World';
}<div>
<h2>Hello, ${name}!</h2>
<p>
<label>Enter your name:</label>
<input type="text" value.bind="name">
</p>
</div><import from="./hello-name"></import>
<div class="app">
<h1>My Aurelia App</h1>
<hello-name></hello-name>
</div>// React: Virtual DOM reconciliation overhead
function UserList({ users, onUserClick }) {
return (
<div>
{users.filter(u => u.isActive).map(user => (
<UserCard key={user.id} user={user} onClick={onUserClick} />
))}
</div>
);
}
// Aurelia: Direct DOM updates, no virtual overhead
export class UserList {
@bindable users: User[];
@bindable onUserClick: (user: User) => void;
}<!-- Aurelia template: Clean HTML, faster rendering -->
<div>
<user-card repeat.for="user of users.filter(u => u.isActive)"
user.bind="user"
on-click.bind="() => onUserClick(user)">
</user-card>
</div>// React: Hooks complexity and re-render management
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const debouncedSearch = useCallback(
debounce(async (searchTerm) => {
setLoading(true);
try {
const data = await searchAPI(searchTerm);
setResults(data);
} finally {
setLoading(false);
}
}, 300),
[]
);
useEffect(() => {
if (query.length > 2) {
debouncedSearch(query);
} else {
setResults([]);
}
}, [query, debouncedSearch]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Loading...</div>}
{results.map(result => <Result key={result.id} data={result} />)}
</div>
);
}
// Aurelia: Simple, intuitive code
export class SearchComponent {
query = '';
results: SearchResult[] = [];
loading = false;
@watch('query')
async queryChanged(newQuery: string) {
if (newQuery.length > 2) {
this.loading = true;
try {
this.results = await searchAPI(newQuery);
} finally {
this.loading = false;
}
} else {
this.results = [];
}
}
}<div>
<input value.bind="query & debounce:300" placeholder="Search...">
<div if.bind="loading">Loading...</div>
<result repeat.for="result of results" data.bind="result"></result>
</div>// React: Complex prop typing
interface UserCardProps {
user: User;
onEdit?: (user: User) => void;
onDelete?: (user: User) => void;
className?: string;
children?: React.ReactNode;
}
const UserCard: React.FC<UserCardProps> = ({
user, onEdit, onDelete, className, children
}) => {
// Component logic
};
// Aurelia: Built-in TypeScript support
export class UserCard {
@bindable user: User;
@bindable onEdit?: (user: User) => void;
@bindable onDelete?: (user: User) => void;
// Automatic type checking, no prop interfaces needed
}// React JSX
<div className={`card ${isActive ? 'active' : ''}`}>
<h2>{user.name}</h2>
<button onClick={() => handleEdit(user)}>Edit</button>
{showDetails && (
<div>
<p>{user.bio}</p>
{user.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
// Aurelia HTML (cleaner, more intuitive)
<div class="card" active.class="isActive">
<h2>${user.name}</h2>
<button click.trigger="handleEdit(user)">Edit</button>
<div if.bind="showDetails">
<p>${user.bio}</p>
<post-card repeat.for="post of user.posts" post.bind="post"></post-card>
</div>
</div>// React
interface Props {
data: any[];
onItemClick: (item: any) => void;
loading?: boolean;
}
const MyComponent: React.FC<Props> = ({ data, onItemClick, loading = false }) => {
// Component logic
};
// Aurelia
export class MyComponent {
@bindable data: any[];
@bindable onItemClick: (item: any) => void;
@bindable loading = false;
// That's it - cleaner and more intuitive
}// React: Context + useReducer or external library
const UserContext = createContext();
function UserProvider({ children }) {
const [state, dispatch] = useReducer(userReducer, initialState);
return (
<UserContext.Provider value={{ state, dispatch }}>
{children}
</UserContext.Provider>
);
}
// Aurelia: Built-in dependency injection
@singleton()
export class UserService {
private users: User[] = [];
addUser(user: User) {
this.users.push(user);
}
getUsers() {
return this.users;
}
}
// Use anywhere with simple injection
export class UserList {
private userService = resolve(UserService);
get users() {
return this.userService.getUsers();
}
}npx makes aurelia my-aurelia-app
cd my-aurelia-app
npm run dev// React component you're used to
const TodoList = ({ todos, onToggle, onDelete }) => (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
);
// Equivalent Aurelia component
export class TodoList {
@bindable todos: Todo[];
@bindable onToggle: (id: number) => void;
@bindable onDelete: (id: number) => void;
}<!-- todo-list.html -->
<ul>
<li repeat.for="todo of todos">
<input type="checkbox"
checked.bind="todo.completed"
change.trigger="onToggle(todo.id)">
<span>${todo.text}</span>
<button click.trigger="onDelete(todo.id)">Delete</button>
</li>
</ul># Try Aurelia now
npx makes aurelia my-first-aurelia-app
cd my-first-aurelia-app
npm run dev${value | converter}promise.bindref.bind or .two-way, not .to-view// Using the convenience method (recommended)
const enhanceRoot = await Aurelia.enhance({
host: document.querySelector('#my-content'),
component: { message: 'Hello World' }
});
// Using instance method
const au = new Aurelia();
const enhanceRoot = await au.enhance({
host: document.querySelector('#my-content'),
component: { message: 'Hello World' }
});// 1. Plain object (most common for simple cases)
const enhanceRoot = await Aurelia.enhance({
host: element,
component: {
message: 'Hello',
items: [1, 2, 3],
handleClick() {
console.log('Clicked!');
}
}
});
// 2. Class instance (when you need constructor logic)
class MyViewModel {
message = 'Hello';
constructor() {
// initialization logic
}
}
const enhanceRoot = await Aurelia.enhance({
host: element,
component: new MyViewModel()
});
// 3. Custom element class (for reusable components)
@customElement({ name: 'my-widget' })
class MyWidget {
@bindable message: string;
}
const enhanceRoot = await Aurelia.enhance({
host: element,
component: MyWidget
});const enhanceRoot = await Aurelia.enhance({ host, component });
// Later, when you're done:
await enhanceRoot.deactivate();<!-- Server-rendered content -->
<div id="user-profile">
<h2>Welcome back!</h2>
<div class="stats">
<span>Loading user data...</span>
</div>
<button id="refresh-btn">Refresh</button>
</div>import Aurelia from 'aurelia';
// Your existing server-rendered element
const profileElement = document.querySelector('#user-profile');
// Enhance with Aurelia interactivity
const enhanceRoot = await Aurelia.enhance({
host: profileElement,
component: {
username: 'Loading...',
loginCount: 0,
async created() {
// Load user data when component initializes
const userData = await fetch('/api/user/profile').then(r => r.json());
this.username = userData.username;
this.loginCount = userData.loginCount;
},
refreshData() {
this.created(); // Reload data
}
}
});
// Update your HTML to use bindings:
// <h2>Welcome back, ${username}!</h2>
// <div class="stats">
// <span>Login count: ${loginCount}</span>
// </div>
// <button click.trigger="refreshData()">Refresh</button><!-- Existing page content -->
<div class="article">
<h1>My Blog Post</h1>
<p>Some content...</p>
<!-- Widget placeholder -->
<div id="comment-widget">
<h3>Comments</h3>
<div class="loading">Loading comments...</div>
</div>
</div>// Enhance just the comment widget
const commentWidget = document.querySelector('#comment-widget');
const enhanceRoot = await Aurelia.enhance({
host: commentWidget,
component: {
comments: [],
newComment: '',
async created() {
this.comments = await this.loadComments();
},
async loadComments() {
return fetch('/api/comments/123').then(r => r.json());
},
async addComment() {
if (!this.newComment.trim()) return;
await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ text: this.newComment }),
headers: { 'Content-Type': 'application/json' }
});
this.newComment = '';
this.comments = await this.loadComments();
}
}
});
// Update HTML to:
// <div id="comment-widget">
// <h3>Comments (${comments.length})</h3>
// <div repeat.for="comment of comments">
// <p>${comment.text}</p>
// </div>
// <div>
// <input value.bind="newComment" placeholder="Add comment...">
// <button click.trigger="addComment()">Post</button>
// </div>
// </div>import { Aurelia, resolve } from 'aurelia';
export class DynamicContentComponent {
private enhancedRoots: Array<any> = [];
constructor(private au = resolve(Aurelia)) {}
async loadMoreContent() {
// Load HTML from server
const response = await fetch('/api/content/next-page');
const htmlContent = await response.text();
// Create container for new content
const container = document.createElement('div');
container.innerHTML = htmlContent;
document.querySelector('#content-area').appendChild(container);
// Enhance the new content
const enhanceRoot = await this.au.enhance({
host: container,
component: {
currentUser: this.currentUser,
likePost: (postId) => this.likePost(postId),
sharePost: (postId) => this.sharePost(postId)
}
});
// Keep track for cleanup
this.enhancedRoots.push(enhanceRoot);
}
// Clean up when component is destroyed
async unbinding() {
for (const root of this.enhancedRoots) {
await root.deactivate();
}
this.enhancedRoots = [];
}
}export class ModalService {
private currentModal: any = null;
async showModal(contentHtml: string, viewModel: any) {
// Create modal element
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<button class="close" click.trigger="closeModal()">×</button>
${contentHtml}
</div>
`;
document.body.appendChild(modal);
// Enhance the modal content
this.currentModal = await Aurelia.enhance({
host: modal,
component: {
...viewModel,
closeModal: () => this.closeModal()
}
});
}
async closeModal() {
if (this.currentModal) {
await this.currentModal.deactivate();
document.querySelector('.modal')?.remove();
this.currentModal = null;
}
}
}import { DI, Registration } from '@aurelia/kernel';
import { LoggerConfiguration, LogLevel } from 'aurelia';
// Create custom container for widget
const widgetContainer = DI.createContainer()
.register(
Registration.singleton('ApiService', MyApiService),
LoggerConfiguration.create({ level: LogLevel.debug })
);
const enhanceRoot = await Aurelia.enhance({
host: document.querySelector('#my-widget'),
component: MyWidget,
container: widgetContainer // Use custom container
});const enhanceRoot = await Aurelia.enhance({
host: element,
component: {
data: null,
// Called when component is being set up
created() {
console.log('Component created');
},
// Called before data binding starts
binding() {
console.log('Starting data binding');
},
// Called after data binding completes
bound() {
console.log('Data binding complete');
},
// Called when component is being attached to DOM
attaching() {
console.log('Attaching to DOM');
},
// Called after component is attached to DOM
attached() {
console.log('Attached to DOM - ready for user interaction');
// Good place for focus, animations, etc.
},
// Called when component is being removed
detaching() {
console.log('Detaching from DOM');
},
// Called when data bindings are being torn down
unbinding() {
console.log('Unbinding data');
// Cleanup subscriptions, timers, etc.
}
}
});unloadingimport { resolve } from '@aurelia/kernel';
import { IRouter } from '@aurelia/router';
export class MyComponent {
readonly router = resolve(IRouter);
}define(
controller: IDryCustomElementController<this>,
hydrationContext: IHydrationContext | null,
definition: CustomElementDefinition
): PartialCustomElementDefinition | void {}hydrating(controller: IContextualCustomElementController<this>): void {}hydrated(controller: ICompiledCustomElementController<this>): void {}created(controller: ICustomElementController<this> | ICustomAttributeController<this>): void {}// Custom Elements
binding(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
// Custom Attributes
binding(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}// Custom Elements
bound(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
// Custom Attributes
bound(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}// Custom Elements
attaching(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
// Custom Attributes
attaching(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}attached(initiator: IHydratedController): void | Promise<void> {}// Custom Elements
detaching(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
// Custom Attributes
detaching(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}// Custom Elements
unbinding(initiator: IHydratedController, parent: IHydratedController | null): void | Promise<void> {}
// Custom Attributes
unbinding(initiator: IHydratedController, parent: IHydratedController): void | Promise<void> {}dispose(): void {}import { lifecycleHooks, ILifecycleHooks, ICustomElementController, IHydratedController } from 'aurelia';
@lifecycleHooks()
export class ComponentLogger implements ILifecycleHooks<MyComponent> {
bound(vm: MyComponent, initiator: IHydratedController, parent: IHydratedController | null) {
console.log(`${vm.constructor.name} bound with data:`, vm.someProperty);
}
detaching(vm: MyComponent, initiator: IHydratedController, parent: IHydratedController | null) {
console.log(`${vm.constructor.name} detaching`);
}
}Components are the building blocks of Aurelia applications. This guide covers creating, configuring, and using components effectively.
change events with mutation observationform[prop] = valueimport { slotted } from '@aurelia/runtime-html';
export class TabContainer {
// Watch all elements in the default slot
@slotted() tabs: Element[];
tabsChanged(newTabs: Element[], oldTabs: Element[]) {
console.log('Tabs changed:', newTabs);
}
}<!-- tab-container.html -->
<div class="tab-container">
<au-slot></au-slot>
</div><tab-container>
<div class="tab">Tab 1</div>
<div class="tab">Tab 2</div>
<div class="tab">Tab 3</div>
</tab-container>import { slotted } from '@aurelia/runtime-html';
export class Accordion {
// Only watch elements with class 'accordion-item'
@slotted('.accordion-item') items: Element[];
itemsChanged(newItems: Element[], oldItems: Element[]) {
console.log(`Accordion now has ${newItems.length} items`);
}
}<!-- accordion.html -->
<div class="accordion">
<au-slot></au-slot>
</div><accordion>
<div class="accordion-item">Item 1</div>
<div class="accordion-item">Item 2</div>
<div>This won't be tracked</div>
<div class="accordion-item">Item 3</div>
</accordion>import { slotted } from '@aurelia/runtime-html';
export class Dashboard {
// Watch elements in the 'header' slot
@slotted('*', 'header') headerItems: Element[];
// Watch elements in the 'sidebar' slot
@slotted('*', 'sidebar') sidebarItems: Element[];
// Watch only buttons in the 'footer' slot
@slotted('button', 'footer') footerButtons: Element[];
}<!-- dashboard.html -->
<div class="dashboard">
<header>
<au-slot name="header"></au-slot>
</header>
<aside>
<au-slot name="sidebar"></au-slot>
</aside>
<main>
<au-slot></au-slot> <!-- default slot -->
</main>
<footer>
<au-slot name="footer"></au-slot>
</footer>
</div><dashboard>
<h1 au-slot="header">Dashboard Title</h1>
<nav au-slot="sidebar">Sidebar Nav</nav>
<p>Main content</p>
<button au-slot="footer">Save</button>
<button au-slot="footer">Cancel</button>
</dashboard>import { slotted } from '@aurelia/runtime-html';
export class MultiSlotComponent {
// Watch all div elements across all slots
@slotted('div', '*') allDivs: Element[];
allDivsChanged(newDivs: Element[], oldDivs: Element[]) {
console.log(`Total div elements across all slots: ${newDivs.length}`);
}
}import { slotted } from '@aurelia/runtime-html';
export class CardList {
@slotted('.card') cards: Element[];
// This method is automatically called when cards change
cardsChanged(newCards: Element[], oldCards: Element[]) {
console.log(`Cards changed from ${oldCards.length} to ${newCards.length}`);
this.updateCardIndexes();
}
private updateCardIndexes() {
this.cards.forEach((card, index) => {
card.setAttribute('data-index', String(index));
});
}
}import { slotted } from '@aurelia/runtime-html';
export class Gallery {
@slotted({
query: 'img',
callback: 'handleImageChange'
}) images: Element[];
handleImageChange(newImages: Element[], oldImages: Element[]) {
console.log('Images changed:', newImages);
}
}import { slotted } from '@aurelia/runtime-html';
export class AdvancedComponent {
@slotted({
query: '.special-item', // CSS selector to filter elements
slotName: 'content', // Name of the slot to watch
callback: 'onItemsChanged' // Custom callback method name
}) specialItems: Element[];
onItemsChanged(newItems: Element[], oldItems: Element[]) {
console.log('Special items updated');
}
}import { slotted } from '@aurelia/runtime-html';
export class TextAwareComponent {
// Track all nodes including text nodes
@slotted('$all') allNodes: Node[];
allNodesChanged(newNodes: Node[], oldNodes: Node[]) {
const textContent = newNodes
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent?.trim())
.filter(Boolean)
.join(' ');
console.log('Text content:', textContent);
}
}import { slotted } from '@aurelia/runtime-html';
export class ComplexSelectors {
// Only direct children with specific class
@slotted('> .direct-child') directChildren: Element[];
// Elements with specific data attribute
@slotted('[data-type="widget"]') widgets: Element[];
// Multiple selectors
@slotted('button, a, input') interactiveElements: Element[];
// Pseudo-selectors
@slotted(':not(.excluded)') includedElements: Element[];
}// tab-panel.ts
import { slotted } from '@aurelia/runtime-html';
export class TabPanel {
@slotted('.tab-header') tabHeaders: Element[];
@slotted('.tab-content') tabContents: Element[];
private activeIndex: number = 0;
tabHeadersChanged(newHeaders: Element[]) {
this.setupTabs();
}
tabContentsChanged(newContents: Element[]) {
this.setupTabs();
}
private setupTabs() {
if (this.tabHeaders.length === 0 || this.tabContents.length === 0) return;
// Setup click handlers on headers
this.tabHeaders.forEach((header, index) => {
header.addEventListener('click', () => this.activateTab(index));
header.setAttribute('role', 'tab');
header.setAttribute('tabindex', index === this.activeIndex ? '0' : '-1');
});
// Setup content panels
this.tabContents.forEach((content, index) => {
content.setAttribute('role', 'tabpanel');
});
this.activateTab(this.activeIndex);
}
private activateTab(index: number) {
if (index < 0 || index >= this.tabHeaders.length) return;
this.activeIndex = index;
// Update headers
this.tabHeaders.forEach((header, i) => {
header.classList.toggle('active', i === index);
header.setAttribute('aria-selected', String(i === index));
header.setAttribute('tabindex', i === index ? '0' : '-1');
});
// Update content
this.tabContents.forEach((content, i) => {
content.classList.toggle('active', i === index);
content.setAttribute('aria-hidden', String(i !== index));
});
}
}<!-- tab-panel.html -->
<div class="tab-panel" role="tablist">
<au-slot></au-slot>
</div><tab-panel>
<div class="tab-header">Profile</div>
<div class="tab-content">
<h2>User Profile</h2>
<p>Profile information goes here...</p>
</div>
<div class="tab-header">Settings</div>
<div class="tab-content">
<h2>Settings</h2>
<p>User settings go here...</p>
</div>
<div class="tab-header">Messages</div>
<div class="tab-content">
<h2>Messages</h2>
<p>User messages go here...</p>
</div>
</tab-panel>import { slotted } from '@aurelia/runtime-html';
import { ICustomElementController } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class ObservableSlots {
private controller = resolve(ICustomElementController);
@slotted('.item') items: Element[];
bound() {
// Get the observer for the slotted property
const observer = (this.items as any).getObserver?.();
if (observer) {
observer.subscribe({
handleSlotChange: (nodes: Node[]) => {
console.log('Items changed via subscription:', nodes);
}
});
}
}
}import { slotted } from '@aurelia/runtime-html';
export class LifecycleExample {
@slotted('.item') items: Element[];
binding() {
console.log('Component binding - watcher will start soon');
}
bound() {
console.log('Component bound - initial items:', this.items);
}
itemsChanged(newItems: Element[]) {
console.log('Items changed:', newItems);
// This will be called:
// 1. After bound() with initial elements
// 2. Whenever slotted content changes
}
unbinding() {
console.log('Component unbinding - watcher will stop');
}
}User Input → DOM Event → Observer → Binding → View Model → Reactive Updates
↑ ↓
Form Element ← DOM Update ← Binding ← Property Change ← View Model<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) {
console.log('Submitting:', { email: this.email, password: this.password });
}
}
}<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;
}<div class="form-group">
<label for="age">Age:</label>
<input id="age"
type="number"
value-as-number.bind="age"
min="18"
max="120" />
</div>
<div class="form-group">
<label for="birthdate">Birth Date:</label>
<input id="birthdate"
type="date"
value-as-date.bind="birthDate" />
</div>
<div class="form-group">
<label for="appointment">Appointment Time:</label>
<input id="appointment"
type="datetime-local"
value-as-date.bind="appointmentTime" />
</div>export class ProfileForm {
age = 25;
birthDate = new Date('1998-01-01');
appointmentTime = new Date();
get isAdult(): boolean {
return this.age >= 18;
}
}<!-- Two-way binding (default for inputs) -->
<input value.two-way="username">
<!-- To-view (view model → view) -->
<input value.to-view="displayName">
<!-- From view (view → view model) -->
<input value.from-view="searchQuery">
<!-- One-time (set once, no updates) -->
<input value.one-time="initialValue"><form submit.trigger="register()" class="registration-form">
<h2>Create Account</h2>
<!-- Username -->
<div class="form-group">
<label for="username">Username *</label>
<input id="username"
type="text"
value.bind="form.username"
required
minlength="3"
maxlength="20">
<small>3-20 characters</small>
</div>
<!-- Email -->
<div class="form-group">
<label for="email">Email *</label>
<input id="email"
type="email"
value.bind="form.email"
required>
</div>
<!-- Password -->
<div class="form-group">
<label for="password">Password *</label>
<input id="password"
type="password"
value.bind="form.password"
required
minlength="8">
<small>At least 8 characters</small>
</div>
<!-- Confirm Password -->
<div class="form-group">
<label for="confirmPassword">Confirm Password *</label>
<input id="confirmPassword"
type="password"
value.bind="form.confirmPassword"
required>
<span if.bind="form.password !== form.confirmPassword" class="error">
Passwords must match
</span>
</div>
<!-- Age -->
<div class="form-group">
<label for="age">Age</label>
<input id="age"
type="number"
value.bind="form.age"
min="13"
max="120">
</div>
<!-- Bio -->
<div class="form-group">
<label for="bio">Bio</label>
<textarea id="bio"
value.bind="form.bio"
maxlength="500"
rows="4"></textarea>
<small>${form.bio.length}/500 characters</small>
</div>
<!-- Submit -->
<button type="submit"
disabled.bind="!isFormValid"
class="btn-primary">
Create Account
</button>
</form>export class Registration {
form = {
username: '',
email: '',
password: '',
confirmPassword: '',
age: null,
bio: ''
};
get isFormValid(): boolean {
return this.form.username.length >= 3 &&
this.form.email.includes('@') &&
this.form.password.length >= 8 &&
this.form.password === this.form.confirmPassword;
}
register() {
if (this.isFormValid) {
console.log('Registering user:', this.form);
// API call here
}
}
}import { templateCompilerHooks, ITemplateCompilerHooks } from 'aurelia';
@templateCompilerHooks
class MyCompilerHook implements ITemplateCompilerHooks {
compiling(template: HTMLElement): void {
// Modify template before compilation
this.addDefaultAttributes(template);
this.injectDevelopmentHelpers(template);
}
private addDefaultAttributes(template: HTMLElement): void {
// Add default attributes to form elements
template.querySelectorAll('input[type="text"]').forEach(input => {
if (!input.hasAttribute('autocomplete')) {
input.setAttribute('autocomplete', 'off');
}
});
}
private injectDevelopmentHelpers(template: HTMLElement): void {
if (__DEV__) {
// Add development-only attributes
template.querySelectorAll('[data-dev-hint]').forEach(el => {
el.setAttribute('title', el.getAttribute('data-dev-hint')!);
});
}
}
}// Global hook registration
container.register(MyCompilerHook);
// Component-level hook
@customElement({
name: 'my-component',
template: '<div>...</div>',
hooks: [MyCompilerHook]
})
export class MyComponent { }import { attributePattern, AttrSyntax } from 'aurelia';
@attributePattern({ pattern: 'PART.vue:PART', symbols: '.:' })
class VueStyleAttributePattern {
'PART.vue:PART'(rawName: string, rawValue: string, parts: string[]): AttrSyntax {
const [target, event] = parts;
return new AttrSyntax(rawName, rawValue, target, 'trigger', [event]);
}
}
// Usage: <button click.vue:prevent="handleClick()">@attributePattern({ pattern: 'PART.PART.PART', symbols: '.' })
class NestedPropertyPattern {
'PART.PART.PART'(rawName: string, rawValue: string, parts: string[]): AttrSyntax {
const [obj, prop, command] = parts;
return new AttrSyntax(rawName, rawValue, `${obj}.${prop}`, command, parts);
}
}
// Usage: <input user.profile.bind="userProfile">import { bindingCommand, BindingCommandInstance, IInstruction } from 'aurelia';
@bindingCommand('throttle')
class ThrottleBindingCommand implements BindingCommandInstance {
ignoreAttr = true; // Take full control of attribute processing
build(info: ICommandBuildInfo, parser: IExpressionParser): IInstruction {
const [delay = '250', event = 'input'] = info.attr.rawValue.split(':');
return new ThrottleInstruction(
parser.parse(info.attr.rawValue),
parseInt(delay, 10),
event
);
}
}
// Usage: <input value.throttle="500:input">@bindingCommand('form')
class FormBindingCommand implements BindingCommandInstance {
build(info: ICommandBuildInfo, parser: IExpressionParser): IInstruction {
const formAttributes = this.collectFormAttributes(info.attr.syntax.target);
return new FormInstruction(
parser.parse(info.attr.rawValue),
formAttributes
);
}
private collectFormAttributes(element: Element): Record<string, string> {
const attrs: Record<string, string> = {};
for (const attr of element.attributes) {
if (attr.name.startsWith('form-')) {
attrs[attr.name.substring(5)] = attr.value;
}
}
return attrs;
}
}import { ITemplateElementFactory, IMarkupCache } from 'aurelia';
class CustomTemplateElementFactory implements ITemplateElementFactory {
private customCache = new Map<string, HTMLTemplateElement>();
createTemplate(markup: string): HTMLTemplateElement {
// Custom caching logic
const cacheKey = this.generateCacheKey(markup);
if (this.customCache.has(cacheKey)) {
return this.customCache.get(cacheKey)!.cloneNode(true) as HTMLTemplateElement;
}
const template = this.createTemplateElement(markup);
this.customCache.set(cacheKey, template);
return template;
}
private generateCacheKey(markup: string): string {
// Custom cache key generation
return `${markup.length}-${this.hashCode(markup)}`;
}
}class SmartTemplateFactory implements ITemplateElementFactory {
createTemplate(markup: string): HTMLTemplateElement {
const wrapped = this.intelligentWrap(markup);
return this.createTemplateElement(wrapped);
}
private intelligentWrap(markup: string): string {
// Custom wrapping logic based on content
if (markup.includes('<tr>')) {
return `<table><tbody>${markup}</tbody></table>`;
}
if (markup.includes('<option>')) {
return `<select>${markup}</select>`;
}
return markup;
}
}import { IResourceResolver, IResourceDescriptions } from 'aurelia';
class DynamicResourceResolver implements IResourceResolver {
resolve(name: string, context: IContainer): IResourceDescriptions | null {
// Check if this is a dynamic component request
if (name.startsWith('dynamic-')) {
return this.resolveDynamicComponent(name, context);
}
return null; // Let default resolver handle it
}
private resolveDynamicComponent(name: string, context: IContainer): IResourceDescriptions {
const componentType = this.loadDynamicComponent(name);
return {
[name]: {
type: componentType,
keyFrom: name,
definition: componentType.definition
}
};
}
}class OptimizedResourceResolver implements IResourceResolver {
private bindablesCache = new Map<Function, Record<string, BindableDefinition>>();
getBindables(Type: Function): Record<string, BindableDefinition> {
if (this.bindablesCache.has(Type)) {
return this.bindablesCache.get(Type)!;
}
const bindables = this.computeBindables(Type);
this.bindablesCache.set(Type, bindables);
return bindables;
}
}@customElement({
name: 'dashboard',
template: `
<template as-custom-element="widget">
<bindable property="title"></bindable>
<bindable property="data"></bindable>
<div class="widget">
<h3>\${title}</h3>
<div class="content" innerhtml.bind="data"></div>
</div>
</template>
<template as-custom-element="chart-widget">
<bindable property="chart-data"></bindable>
<widget title="Chart" data.bind="renderChart(chartData)"></widget>
</template>
<div class="dashboard">
<chart-widget chart-data.bind="metrics"></chart-widget>
</div>
`
})
export class Dashboard {
renderChart(data: any): string {
return `<canvas data-chart="${JSON.stringify(data)}"></canvas>`;
}
}@customElement({
name: 'dynamic-layout',
template: `<div ref="container"></div>`
})
export class DynamicLayout {
@ViewSlot() container!: ViewSlot;
attached(): void {
this.createLocalTemplate();
}
private createLocalTemplate(): void {
const template = `
<template as-custom-element="dynamic-item">
<bindable property="item"></bindable>
<div class="item">\${item.name}</div>
</template>
`;
this.container.add(this.viewFactory.create(template));
}
}class CustomCompiler {
compileWithContext(template: string, parentContext?: ICompilationContext): ICompiledTemplate {
const context = this.createCompilationContext(parentContext);
// Add custom resources to context
context.addResource('custom-element', MyCustomElement);
context.addResource('value-converter', MyConverter);
return this.compile(template, context);
}
private createCompilationContext(parent?: ICompilationContext): ICompilationContext {
const context = new CompilationContext(parent);
// Configure context for specific compilation needs
context.resolveResources = true;
context.debug = __DEV__;
return context;
}
}class ScopedCompiler {
compileWithScope(template: string, scope: Record<string, any>): ICompiledTemplate {
const container = this.createScopedContainer(scope);
const context = new CompilationContext(container);
return this.compile(template, context);
}
private createScopedContainer(scope: Record<string, any>): IContainer {
const container = DI.createContainer();
// Register scope variables as services
Object.entries(scope).forEach(([key, value]) => {
container.register(Registration.instance(key, value));
});
return container;
}
}class CachedTemplateCompiler {
private compilationCache = new Map<string, ICompiledTemplate>();
private templateHashCache = new Map<string, string>();
compile(template: string, context: ICompilationContext): ICompiledTemplate {
const hash = this.getTemplateHash(template, context);
if (this.compilationCache.has(hash)) {
return this.compilationCache.get(hash)!;
}
const compiled = this.performCompilation(template, context);
this.compilationCache.set(hash, compiled);
return compiled;
}
private getTemplateHash(template: string, context: ICompilationContext): string {
const contextHash = this.getContextHash(context);
return `${template.length}-${contextHash}`;
}
}interface CompilationOptions {
resolveResources?: boolean;
debug?: boolean;
enhance?: boolean;
aot?: boolean;
}
class OptimizedCompiler {
compile(template: string, options: CompilationOptions = {}): ICompiledTemplate {
const context = this.createOptimizedContext(options);
if (options.aot) {
return this.compileAOT(template, context);
}
return this.compileJIT(template, context);
}
private createOptimizedContext(options: CompilationOptions): ICompilationContext {
const context = new CompilationContext();
context.resolveResources = options.resolveResources ?? true;
context.debug = options.debug ?? __DEV__;
context.enhance = options.enhance ?? false;
return context;
}
}<!-- No parameters -->
${(() => 42)()}
<!-- Single parameter (parentheses optional) -->
${items.map(item => item.name)}
${items.map((item) => item.name)}
<!-- Multiple parameters -->
${items.reduce((sum, item) => sum + item.price, 0)}
<!-- Rest parameters -->
${((...args) => args[0] + args[1] + args[2])(1, 2, 3)}
<!-- Nested arrow functions -->
${((a => b => a + b)(1))(2)}<div repeat.for="item of items.filter(item => item.isActive)">
${item.name}
</div><!-- Text interpolation -->
${items.filter(item => item.active).length}
<!-- Repeat bindings -->
<div repeat.for="user of users.sort((a, b) => a.name.localeCompare(b.name))">
${user.name}
</div>
<!-- Event bindings -->
<button click.trigger="() => doSomething()">Click</button>
<!-- Attribute bindings -->
<div my-attr.bind="value => transform(value)"></div>
<!-- With binding behaviors and value converters -->
<div repeat.for="item of items.filter(i => i.active) & debounce:500">
${item.name}
</div>
<div repeat.for="item of items.sort((a, b) => a - b) | take:10">
${item.name}
</div><!-- ❌ Block bodies with curly braces -->
${items.filter(item => { return item.active; })}
<!-- ❌ Default parameters -->
${items.map((item = {}) => item.name)}
<!-- ❌ Destructuring parameters -->
${items.map(([first]) => first)}
${items.map(({name}) => name)}<!-- Filtering -->
<div repeat.for="item of items.filter(item => item.isVisible)">
${item.name}
</div>
<!-- Sorting numbers -->
<div repeat.for="num of numbers.sort((a, b) => a - b)">
${num}
</div>
<!-- Mapping and joining -->
${items.map(item => item.name.toUpperCase()).join(', ')}
<!-- Array search methods -->
${items.find(item => item.id === selectedId)?.name}
${items.findIndex(item => item.active)}
${items.indexOf(targetValue)}
${items.lastIndexOf(targetValue)}
${items.includes(searchValue)}
<!-- Array access -->
${items.at(-1)} <!-- Last item -->
<!-- Aggregation -->
${cartItems.reduce((total, item) => total + item.price, 0)}
${cartItems.reduceRight((acc, item) => acc + item.value)}
<!-- Array tests -->
${items.every(item => item.valid)}
${items.some(item => item.hasError)}
<!-- Array transformation -->
${nested.flat()}
${items.flatMap(item => item.tags)}
${items.slice(0, 5)}<div repeat.for="product of products
.filter(p => p.inStock && p.category === currentCategory)
.sort((a, b) => b.rating - a.rating)
.slice(0, 10)">
${product.name} - ${product.rating}⭐
</div><!-- Changes to item.visible will trigger re-evaluation -->
<div repeat.for="item of items.filter(item => item.visible)">
${item.name}
</div><!-- Simple event handlers -->
<button click.trigger="() => count++">Increment</button>
<!-- Passing event data -->
<input input.trigger="event => search(event.target.value)">
<!-- Multiple parameters -->
<button click.trigger="event => deleteItem(event, item.id)">Delete</button><!-- Pass functions to custom attributes -->
<div validate.bind="value => value.length > 3">
<input value.bind="inputValue">
</div><!-- Array transformations -->
<p>Tags: ${tags.map(tag => tag.toUpperCase()).join(', ')}</p>
<!-- Calculations -->
<p>Total: $${items.reduce((sum, item) => sum + item.price, 0).toFixed(2)}</p>
<!-- Conditional text -->
<p>Status: ${items.every(item => item.completed) ? 'All Done!' : 'In Progress'}</p>
<!-- String operations -->
<p>Names: ${users.map(u => u.name).join(' and ')}</p><!-- Access view model properties -->
${items.filter(item => item.userId === $this.currentUserId).length}
<!-- Access parent scope in nested contexts -->
<div with.bind="childData">
${items.find(item => item.id === $parent.selectedId)?.name}
</div>
<!-- Complex scope navigation -->
<div with.bind="{level: 1}">
<div with.bind="{level: 2}">
<div with.bind="{level: 3}">
<!-- Access different scope levels -->
${(level => `Current: ${level}, Parent: ${$parent.level}, Root: ${$parent.$parent.level}`)($this.level)}
</div>
</div>
</div><!-- Flatten nested hierarchies -->
<div repeat.for="item of items.flatMap(x =>
[x].concat(x.children.flatMap(y => [y].concat(y.children))))">
${item.name}
</div>
<!-- Access parent variables in nested operations -->
<div repeat.for="item of items.flatMap(x =>
x.children.flatMap(y => ([x, y].concat(y.children))))">
${item.name}
</div>
<!-- Complex hierarchical flattening with metadata -->
<div repeat.for="item of categories.flatMap(category =>
category.products
.filter(p => p.active)
.map(product => ({ ...product, categoryName: category.name })))">
${item.name} (${item.categoryName})
</div><input value.bind="searchQuery" placeholder="Search products...">
<input value.bind="minPrice" type="number" placeholder="Min price">
<input value.bind="maxPrice" type="number" placeholder="Max price">
<div repeat.for="product of products
.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
.filter(p => p.price >= (minPrice || 0))
.filter(p => p.price <= (maxPrice || 999999))
.sort((a, b) => b.rating - a.rating)">
${product.name} - $${product.price} (${product.rating}⭐)
</div><!-- Simple IIFE -->
${(a => a)(42)}
<!-- Nested arrow functions -->
${(a => b => a + b)(1)(2)}
<!-- Rest parameters -->
${((...args) => args[0] + args[1] + args[2])(1, 2, 3)}
<!-- Complex object creation with property access -->
${(((e) => ({ a: e.value }))({ value: 'test' })).a}
<!-- Multi-step calculations -->
${((price, tax) => (price * (1 + tax)).toFixed(2))(100, 0.08)}<!-- Will update when item.status or item.priority changes -->
<div repeat.for="item of items.filter(item =>
item.status === 'active' && item.priority > 3)">
${item.name}
</div><!-- These will automatically update when arrays change -->
${items.map(item => item.name)} <!-- ✅ Observes array changes -->
${items.filter(item => item.active)} <!-- ✅ Observes array changes -->
${items.reduce((sum, item) => sum + item.price, 0)} <!-- ✅ Observes array changes --><!-- ✅ Works perfectly for text interpolation -->
${items.sort((a, b) => a - b)}
<!-- ⚠️ Use slice() first for repeat.for to avoid mutation issues -->
<div repeat.for="item of items.slice().sort((a, b) => a.order - b.order)">
${item.name}
</div>
<!-- ⚠️ Direct sort in repeat.for can cause issues due to array mutation -->
<!-- This pattern is skipped in framework tests due to flush queue complications -->
<div repeat.for="item of items.sort((a, b) => a.order - b.order)">
${item.name} <!-- Can cause problems when items array is mutated -->
</div><!-- ❌ Problematic: sort mutates original array -->
<div repeat.for="item of items.sort((a, b) => a.name.localeCompare(b.name))">
<!-- ✅ Better: use slice() to avoid mutation -->
<div repeat.for="item of items.slice().sort((a, b) => a.name.localeCompare(b.name))"><!-- ❌ Too complex for templates -->
${items.filter(item => {
const category = categories.find(c => c.id === item.categoryId);
return category && category.active && item.stock > 0;
})}
<!-- ✅ Move complex logic to view model -->
${getAvailableItems(items, categories)}import { bindable } from 'aurelia';
export class Accordion {
@bindable allowMultiple = false;
@bindable openPanels: number[] = [];
togglePanel(index: number) {
if (this.allowMultiple) {
// Multiple panels can be open
const panelIndex = this.openPanels.indexOf(index);
if (panelIndex > -1) {
this.openPanels.splice(panelIndex, 1);
} else {
this.openPanels.push(index);
}
} else {
// Only one panel can be open
if (this.isPanelOpen(index)) {
this.openPanels = [];
} else {
this.openPanels = [index];
}
}
}
isPanelOpen(index: number): boolean {
return this.openPanels.includes(index);
}
}<div class="accordion">
<au-slot></au-slot>
</div>import { bindable, resolve } from 'aurelia';
import { Accordion } from './accordion';
export class AccordionPanel {
@bindable title = '';
@bindable index = 0;
private accordion = resolve(Accordion);
get isOpen(): boolean {
return this.accordion.isPanelOpen(this.index);
}
toggle() {
this.accordion.togglePanel(this.index);
}
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggle();
}
}
}<div class="accordion-panel \${isOpen ? 'accordion-panel--open' : ''}">
<button
type="button"
class="accordion-panel__header"
click.trigger="toggle()"
keydown.trigger="handleKeyDown($event)"
aria-expanded.bind="isOpen">
<span class="accordion-panel__title">\${title}</span>
<svg class="accordion-panel__icon" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 12L2 6h12z" fill="currentColor"/>
</svg>
</button>
<div
class="accordion-panel__content"
aria-hidden.bind="!isOpen">
<div class="accordion-panel__body">
<au-slot></au-slot>
</div>
</div>
</div>.accordion {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.accordion-panel {
border-bottom: 1px solid #e5e7eb;
}
.accordion-panel:last-child {
border-bottom: none;
}
.accordion-panel__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border: none;
cursor: pointer;
transition: background 0.15s;
text-align: left;
font-size: 16px;
font-weight: 500;
}
.accordion-panel__header:hover {
background: #f9fafb;
}
.accordion-panel__header:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
z-index: 1;
}
.accordion-panel__title {
color: #111827;
}
.accordion-panel__icon {
color: #6b7280;
transition: transform 0.2s;
flex-shrink: 0;
}
.accordion-panel--open .accordion-panel__icon {
transform: rotate(180deg);
}
.accordion-panel__content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.accordion-panel--open .accordion-panel__content {
max-height: 1000px; /* Adjust based on your content */
}
.accordion-panel__body {
padding: 0 20px 16px;
color: #374151;
line-height: 1.6;
}<accordion>
<accordion-panel index="0" title="What is Aurelia?">
Aurelia is a modern JavaScript framework for building web applications.
</accordion-panel>
<accordion-panel index="1" title="How do I install Aurelia?">
You can install Aurelia using npm: <code>npm install aurelia</code>
</accordion-panel>
<accordion-panel index="2" title="Where can I learn more?">
Check out the official documentation at docs.aurelia.io
</accordion-panel>
</accordion><accordion allow-multiple.bind="true">
<accordion-panel index="0" title="Account Settings">
<p>Manage your account settings here.</p>
</accordion-panel>
<accordion-panel index="1" title="Privacy Settings">
<p>Control your privacy preferences.</p>
</accordion-panel>
<accordion-panel index="2" title="Notification Settings">
<p>Configure your notification preferences.</p>
</accordion-panel>
</accordion>// your-component.ts
export class FAQPage {
openPanels = [0]; // First panel open by default
openAll() {
this.openPanels = [0, 1, 2, 3];
}
closeAll() {
this.openPanels = [];
}
}<!-- your-component.html -->
<div>
<button click.trigger="openAll()">Expand All</button>
<button click.trigger="closeAll()">Collapse All</button>
</div>
<accordion allow-multiple.bind="true" open-panels.bind="openPanels">
<accordion-panel index="0" title="Question 1">Answer 1</accordion-panel>
<accordion-panel index="1" title="Question 2">Answer 2</accordion-panel>
<accordion-panel index="2" title="Question 3">Answer 3</accordion-panel>
<accordion-panel index="3" title="Question 4">Answer 4</accordion-panel>
</accordion><accordion>
<accordion-panel index="0" title="Product Features">
<ul>
<li>Feature 1: Fast performance</li>
<li>Feature 2: Easy to use</li>
<li>Feature 3: Highly customizable</li>
</ul>
</accordion-panel>
<accordion-panel index="1" title="Pricing">
<div class="pricing-grid">
<div class="plan">
<h3>Basic</h3>
<p>$9/month</p>
</div>
<div class="plan">
<h3>Pro</h3>
<p>$29/month</p>
</div>
</div>
</accordion-panel>
</accordion>import { createFixture } from '@aurelia/testing';
import { Accordion } from './accordion';
import { AccordionPanel } from './accordion-panel';
describe('Accordion', () => {
it('toggles panel open/closed', async () => {
const { getAllBy, trigger, stop } = await createFixture
.html`
<accordion>
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const panel = getAllBy('.accordion-panel')[0];
// Initially closed
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
// Click to open
trigger.click('.accordion-panel__header');
expect(panel.classList.contains('accordion-panel--open')).toBe(true);
// Click to close
trigger.click('.accordion-panel__header');
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
await stop(true);
});
it('allows only one panel open when allowMultiple=false', async () => {
const { component, getAllBy, trigger, stop } = await createFixture
.html`
<accordion allow-multiple.bind="false">
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
<accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
// Open first panel
trigger.click('.accordion-panel:first-child .accordion-panel__header');
expect(component.openPanels).toEqual([0]);
// Open second panel
trigger.click('.accordion-panel:nth-child(2) .accordion-panel__header');
expect(component.openPanels).toEqual([1]); // First closed, second open
await stop(true);
});
it('allows multiple panels open when allowMultiple=true', async () => {
const { component, getAllBy, trigger, stop } = await createFixture
.html`
<accordion allow-multiple.bind="true">
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
<accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
// Open first panel
trigger.click('.accordion-panel:first-child .accordion-panel__header');
expect(component.openPanels).toEqual([0]);
// Open second panel
trigger.click('.accordion-panel:nth-child(2) .accordion-panel__header');
expect(component.openPanels).toEqual([0, 1]); // Both open
await stop(true);
});
it('supports keyboard navigation', async () => {
const { getAllBy, trigger, stop } = await createFixture
.html`
<accordion>
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const button = getAllBy('.accordion-panel__header')[0];
const panel = getAllBy('.accordion-panel')[0];
// Press Enter to open
trigger.keydown(button, { key: 'Enter' });
expect(panel.classList.contains('accordion-panel--open')).toBe(true);
// Press Space to close
trigger.keydown(button, { key: ' ' });
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
await stop(true);
});
});export class AnimatedAccordion extends Accordion {
@bindable onBeforeOpen?: (index: number) => void;
@bindable onAfterOpen?: (index: number) => void;
togglePanel(index: number) {
const wasOpen = this.isPanelOpen(index);
if (!wasOpen && this.onBeforeOpen) {
this.onBeforeOpen(index);
}
super.togglePanel(index);
if (!wasOpen && this.onAfterOpen) {
setTimeout(() => this.onAfterOpen!(index), 300); // After animation
}
}
}<accordion-panel index="0" title="Custom Icon">
<svg au-slot="icon" width="20" height="20">
<!-- Custom icon -->
</svg>
Panel content here
</accordion-panel>export class LazyAccordionPanel extends AccordionPanel {
@bindable loadContent?: () => Promise<any>;
content: any = null;
loaded = false;
async toggle() {
super.toggle();
if (this.isOpen && !this.loaded && this.loadContent) {
this.content = await this.loadContent();
this.loaded = true;
}
}
}export class UserCard {
name =
<div class="user-card">
<
import template from './product-name-search.html?raw';declare module '*.html?raw' {
const content: string;
export default content;
}import { customElement } from 'aurelia';
@customElement({
name: 'user-card',
template: `
<div class="user-card">
<h3>\${name}</h3>
<p>\${email}</p>
</div>
`
})
export class UserCard {
name = 'John Doe';
email = '[email protected]';
}@customElement('user-card')
export class UserCard {
// Component logic
}import template from './custom-template.html?raw';
@customElement({
name: 'data-widget',
template, // External file
})
export class DataWidget {}
@customElement({
name: 'inline-widget',
template: '<div>Inline template</div>',
})
export class InlineWidget {}
@customElement({
name: 'viewless-widget',
template: null,
})
export class ViewlessWidget {}import { ChildComponent } from './child-component';
@customElement({
name: 'parent-widget',
dependencies: [ChildComponent] // Available without <import>
})export class UserCard {
static $au = {
type: 'custom-element',
name: 'user-card'
};
}import { CustomElement } from '@aurelia/runtime-html';
const MyComponent = CustomElement.define({
name: 'test-component',
template: '<span>\${message}</span>'
});<bindable name="status"></bindable>
<bindable name="message"></bindable>
<span class="badge badge-\${status}">\${message}</span><import from="./status-badge.html"></import>
<status-badge status="success" message="Complete"></status-badge>import { bindable, customElement } from 'aurelia';
import * as nprogress from 'nprogress';
@customElement({
name: 'progress-indicator',
template: null
})
export class ProgressIndicator {
@bindable loading = false;
loadingChanged(newValue: boolean) {
newValue ? nprogress.start() : nprogress.done();
}
}import Aurelia from 'aurelia';
import { UserCard } from './components/user-card';
Aurelia
.register(UserCard)
.app(MyApp)
.start();<import from="./user-card"></import>
<!-- or with alias -->
<import from="./user-card" as="profile-card"></import>
<user-card user.bind="currentUser"></user-card>
<profile-card user.bind="selectedUser"></profile-card>import { customElement, containerless } from 'aurelia';
@customElement({ name: 'list-wrapper' })
@containerless
export class ListWrapper {
// Component logic
}@customElement({
name: 'list-wrapper',
containerless: true
})
export class ListWrapper {}export class UserProfile {
constructor() {
// Component instantiation
}
binding() {
// Before bindings are processed
}
bound() {
// After bindings are set
}
attached() {
// Component is in the DOM
}
detaching() {
// Before removal from DOM
}
}import { bindable, BindingMode } from 'aurelia';
export class UserCard {
@bindable user: User;
@bindable isActive: boolean = false;
@bindable({ mode: BindingMode.twoWay }) selectedId: string;
userChanged(newUser: User, oldUser: User) {
// Called when user property changes
}
}<user-card
user.bind="currentUser"
is-active.bind="userIsActive"
selected-id.two-way="selectedUserId">
</user-card>import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
@customElement({
name: 'isolated-widget',
template: '<div class="widget"><slot></slot></div>',
dependencies: [
shadowCSS(`
.widget {
border: 1px solid var(--widget-border, #ddd);
padding: 16px;
}
`)
]
})
@useShadowDOM({ mode: 'open' })
export class IsolatedWidget {
// Styles and DOM are fully encapsulated from outside
}import { customElement, processContent, INode } from 'aurelia';
@customElement({ name: 'card-grid' })
export class CardGrid {
@processContent()
static processContent(node: INode) {
// Transform <card> elements into proper markup
const cards = node.querySelectorAll('card');
cards.forEach(card => {
card.classList.add('card-item');
// Additional transformations...
});
}
}import { resolve, Aurelia } from 'aurelia';
export class DynamicContent {
private readonly au = resolve(Aurelia);
async enhanceContent() {
const element = document.getElementById('server-rendered');
await this.au.enhance({
host: element,
component: { data: this.dynamicData }
});
}
}import { watch, bindable } from 'aurelia';
export class ChartWidget {
@bindable data: ChartData[];
@bindable config: ChartConfig;
@watch('data')
@watch('config')
onDataChange(newValue: any, oldValue: any, propertyName: string) {
this.updateChart();
}
}import { children, slotted } from 'aurelia';
export class TabContainer {
@children('tab-item') tabItems: TabItem[];
@slotted('tab-panel') panels: TabPanel[];
tabItemsChanged(newItems: TabItem[]) {
this.syncTabs();
}
}import { capture, customElement } from 'aurelia';
@customElement({ name: 'flex-wrapper' })
@capture() // Captures all unrecognized attributes
export class FlexWrapper {}import { customElement } from 'aurelia';
@customElement({
name: 'primary-button',
aliases: ['btn-primary', 'p-btn']
})
export class PrimaryButton {}import { bindable, resolve } from 'aurelia';
import { ILogger } from '@aurelia/kernel';
interface User {
id: string;
name: string;
email: string;
}
export class UserProfile {
@bindable user: User;
private readonly logger = resolve(ILogger);
attached() {
this.logger.info('Profile loaded', { userId: this.user.id });
}
}Complete getting started guide for Aurelia 2 - from installation to building your first interactive application in 15 minutes.
Forward bindings from a custom element to its inner template using Aurelia's spread operators.
export class FormInput {
@bindable label
@bindable value
}<label>${label}
<input value.bind="value">
</label>export class FormInput {
@bindable label
@bindable value
@bindable type
@bindable tooltip
@bindable arias
@bindable etc
}repeat.for documentation// webpack.config.cjs
module.exports = {
module: {
rules: [
{ test: /\.html$/i, type: 'asset/source' },
],
},
};import template from './product-name-search.html';
declare module '*.html' {
const content: string;
export default content;
}// src/services/notification-service.ts
import { DI } from '@aurelia/kernel';
export interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
duration: number; // milliseconds, 0 = no auto-dismiss
dismissible: boolean;
timestamp: Date;
expiresAt?: number;
remaining?: number;
}
export const INotificationService = DI.createInterface<INotificationService>(
'INotificationService',
x => x.singleton(NotificationService)
);
export interface INotificationService {
readonly notifications: Notification[];
show(options: Partial<Notification>): string;
success(title: string, message: string, duration?: number): string;
error(title: string, message: string, duration?: number): string;
warning(title: string, message: string, duration?: number): string;
info(title: string, message: string, duration?: number): string;
dismiss(id: string): void;
clear(): void;
}
class NotificationService implements INotificationService {
notifications: Notification[] = [];
private nextId = 1;
private timers = new Map<string, number>();
private progressTimers = new Map<string, number>();
show(options: Partial<Notification>): string {
const notification: Notification = {
id: `notification-${this.nextId++}`,
type: options.type || 'info',
title: options.title || '',
message: options.message || '',
duration: options.duration !== undefined ? options.duration : 5000,
dismissible: options.dismissible !== undefined ? options.dismissible : true,
timestamp: new Date(),
remaining: options.duration ?? 5000,
expiresAt: options.duration ? Date.now() + options.duration : undefined
};
// Add to beginning of array (newest first)
this.notifications.unshift(notification);
// Auto-dismiss if duration > 0
if (notification.duration > 0) {
const timer = window.setTimeout(() => {
this.dismiss(notification.id);
}, notification.duration);
this.timers.set(notification.id, timer);
const progress = window.setInterval(() => {
if (!notification.expiresAt) return;
const remaining = Math.max(notification.expiresAt - Date.now(), 0);
notification.remaining = remaining;
if (remaining <= 0) {
window.clearInterval(progress);
this.progressTimers.delete(notification.id);
}
}, 100);
this.progressTimers.set(notification.id, progress);
}
return notification.id;
}
success(title: string, message: string, duration = 5000): string {
return this.show({ type: 'success', title, message, duration });
}
error(title: string, message: string, duration = 0): string {
// Errors don't auto-dismiss by default
return this.show({ type: 'error', title, message, duration });
}
warning(title: string, message: string, duration = 7000): string {
return this.show({ type: 'warning', title, message, duration });
}
info(title: string, message: string, duration = 5000): string {
return this.show({ type: 'info', title, message, duration });
}
dismiss(id: string): void {
// Clear timer if exists
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
this.timers.delete(id);
}
const progress = this.progressTimers.get(id);
if (progress) {
clearInterval(progress);
this.progressTimers.delete(id);
}
// Remove notification
const index = this.notifications.findIndex(n => n.id === id);
if (index !== -1) {
this.notifications.splice(index, 1);
}
}
clear(): void {
// Clear all timers
this.timers.forEach(timer => clearTimeout(timer));
this.timers.clear();
this.progressTimers.forEach(interval => clearInterval(interval));
this.progressTimers.clear();
// Clear all notifications
this.notifications = [];
}
}// src/components/notification-container.ts
import { resolve } from '@aurelia/kernel';
import { INotificationService } from '../services/notification-service';
export class NotificationContainer {
private notificationService = resolve(INotificationService);
get notifications() {
return this.notificationService.notifications;
}
dismiss(id: string) {
this.notificationService.dismiss(id);
}
getIcon(type: string): string {
switch (type) {
case 'success': return '✓';
case 'error': return '✕';
case 'warning': return '⚠';
case 'info': return 'ⓘ';
default: return '';
}
}
getProgressWidth(notification: any): number {
if (notification.duration === 0) return 0;
const remaining = notification.remaining ?? notification.duration;
return Math.max((remaining / notification.duration) * 100, 0);
}
}<!-- src/components/notification-container.html -->
<div class="notification-container">
<div
repeat.for="notification of notifications"
class="notification notification-${notification.type}">
<div class="notification-icon">
${getIcon(notification.type)}
</div>
<div class="notification-content">
<div class="notification-title">${notification.title}</div>
<div class="notification-message">${notification.message}</div>
<!-- Progress bar for auto-dismiss -->
<div
if.bind="notification.duration > 0"
class="notification-progress">
<div
class="notification-progress-bar"
style.width.bind="getProgressWidth(notification) + '%'">
</div>
</div>
</div>
<button
if.bind="notification.dismissible"
type="button"
click.trigger="dismiss(notification.id)"
class="notification-close"
aria-label="Dismiss notification">
×
</button>
</div>
</div>.notification-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
width: calc(100% - 2rem);
}
.notification {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
position: relative;
overflow: hidden;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
flex-shrink: 0;
}
.notification-success {
border-left: 4px solid #4caf50;
}
.notification-success .notification-icon {
background-color: #4caf50;
color: white;
}
.notification-error {
border-left: 4px solid #f44336;
}
.notification-error .notification-icon {
background-color: #f44336;
color: white;
}
.notification-warning {
border-left: 4px solid #ff9800;
}
.notification-warning .notification-icon {
background-color: #ff9800;
color: white;
}
.notification-info {
border-left: 4px solid #2196f3;
}
.notification-info .notification-icon {
background-color: #2196f3;
color: white;
}
.notification-content {
flex-grow: 1;
}
.notification-title {
font-weight: 600;
margin-bottom: 0.25rem;
color: #333;
}
.notification-message {
font-size: 0.875rem;
color: #666;
line-height: 1.4;
}
.notification-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 24px;
height: 24px;
line-height: 1;
flex-shrink: 0;
}
.notification-close:hover {
color: #333;
}
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background-color: rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.notification-progress-bar {
height: 100%;
background-color: currentColor;
transition: width 0.1s linear;
}
.notification-success .notification-progress-bar {
background-color: #4caf50;
}
.notification-error .notification-progress-bar {
background-color: #f44336;
}
.notification-warning .notification-progress-bar {
background-color: #ff9800;
}
.notification-info .notification-progress-bar {
background-color: #2196f3;
}
/* Responsive */
@media (max-width: 640px) {
.notification-container {
top: auto;
bottom: 0;
left: 0;
right: 0;
max-width: 100%;
width: 100%;
border-radius: 0;
}
.notification {
border-radius: 0;
border-left: none;
border-top: 4px solid;
}
}// src/main.ts
import Aurelia from 'aurelia';
import { NotificationContainer } from './components/notification-container';
import { INotificationService } from './services/notification-service';
Aurelia
.register(NotificationContainer, INotificationService)
.app(component)
.start();<!-- src/my-app.html -->
<notification-container></notification-container>
<!-- Your app content -->
<au-viewport></au-viewport>// src/pages/dashboard.ts
import { resolve } from '@aurelia/kernel';
import { INotificationService } from '../services/notification-service';
export class Dashboard {
private notifications = resolve(INotificationService);
async saveData() {
try {
await this.apiClient.save(this.data);
this.notifications.success(
'Saved!',
'Your changes have been saved successfully.'
);
} catch (error) {
this.notifications.error(
'Error',
'Failed to save changes. Please try again.',
0 // Don't auto-dismiss errors
);
}
}
showWarning() {
this.notifications.warning(
'Low Storage',
'You are running low on storage space.',
7000
);
}
showInfo() {
this.notifications.info(
'Tip',
'You can use keyboard shortcuts to navigate faster.'
);
}
}show(options: Partial<Notification>): string {
// Clear existing notifications of the same type
this.notifications = this.notifications.filter(n => n.type !== options.type);
// ... rest of implementation
}<div class="notification-container notification-container-${position}">.notification-container-top-right { top: 1rem; right: 1rem; }
.notification-container-top-left { top: 1rem; left: 1rem; }
.notification-container-bottom-right { bottom: 1rem; right: 1rem; }
.notification-container-bottom-left { bottom: 1rem; left: 1rem; }export interface NotificationAction {
label: string;
callback: () => void | Promise<void>;
}
export interface Notification {
// ... existing properties
actions?: NotificationAction[];
}<div if.bind="notification.actions" class="notification-actions">
<button
repeat.for="action of notification.actions"
type="button"
click.trigger="action.callback()"
class="btn btn-small">
${action.label}
</button>
</div>pauseTimer(id: string) {
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
}
}
resumeTimer(notification: Notification) {
if (notification.duration > 0) {
const elapsed = Date.now() - notification.timestamp.getTime();
const remaining = Math.max(0, notification.duration - elapsed);
const timer = setTimeout(() => {
this.dismiss(notification.id);
}, remaining);
this.timers.set(notification.id, timer);
}
}<div
mouseover.trigger="notifications.pauseTimer(notification.id)"
mouseout.trigger="notifications.resumeTimer(notification)">
<!-- notification content -->
</div><!DOCTYPE html>
<html>
<head>
<title>Aurelia 2 Demo</title>
</head>
<body>
<my-app></my-app>
<script type="module">
import Aurelia, { CustomElement } from 'https://cdn.jsdelivr.net/npm/aurelia@latest/+esm';
const App = CustomElement.define({
name: 'my-app',
template: `
<h1>Hello, \${name}!</h1>
<input value.bind="name" placeholder="Enter your name">
<p>You typed: \${name}</p>
`
}, class {
name = 'World';
});
new Aurelia()
.app({ component: App, host: document.querySelector('my-app') })
.start();
</script>
</body>
</html>npx makes aureliacd my-task-app
npm run devmy-task-app/
├── src/
│ ├── main.ts # Application entry point
│ ├── my-app.html # Root component template
│ ├── my-app.ts # Root component logic
│ └── my-app.css # Component styles
├── index.html # Main HTML file
├── vite.config.js # Vite configuration
└── package.json # Dependencies and scriptsexport class MyApp {
message = 'Hello World!';
// Methods and properties go here
}<h1>${message}</h1>
<!-- HTML template goes here --><div class="app">
<h1>My Task Manager</h1>
<!-- Add new task form -->
<div class="add-task">
<input
value.bind="newTaskText"
placeholder="Enter a new task..."
keydown.trigger="addTaskOnEnter($event)">
<button click.trigger="addTask()">Add Task</button>
</div>
<!-- Task counter -->
<p class="task-count">
${tasks.length} task${tasks.length === 1 ? '' : 's'} total
</p>
<!-- Task list -->
<ul class="task-list">
<li repeat.for="task of tasks" class="task-item">
<label class="task-label">
<input
type="checkbox"
checked.bind="task.completed"
change.trigger="updateTaskCount()">
<span class="${task.completed ? 'completed' : ''}">${task.text}</span>
</label>
<button click.trigger="removeTask(task)" class="remove-btn">×</button>
</li>
</ul>
<!-- Empty state -->
<p if.bind="tasks.length === 0" class="empty-state">
No tasks yet. Add one above!
</p>
<!-- Completed tasks counter -->
<p if.bind="completedTaskCount > 0" class="completed-count">
✅ ${completedTaskCount} completed
</p>
</div>export class MyApp {
newTaskText = '';
tasks: Task[] = [
{ id: 1, text: 'Learn Aurelia basics', completed: false },
{ id: 2, text: 'Build a task app', completed: false },
{ id: 3, text: 'Celebrate! 🎉', completed: false }
];
private nextId = 4;
get completedTaskCount(): number {
return this.tasks.filter(task => task.completed).length;
}
addTask(): void {
if (this.newTaskText.trim()) {
this.tasks.push({
id: this.nextId++,
text: this.newTaskText.trim(),
completed: false
});
this.newTaskText = '';
}
}
addTaskOnEnter(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.addTask();
}
}
removeTask(taskToRemove: Task): void {
this.tasks = this.tasks.filter(task => task !== taskToRemove);
}
updateTaskCount(): void {
// This method triggers reactivity update for computed properties
// In most cases, Aurelia handles this automatically
}
}
interface Task {
id: number;
text: string;
completed: boolean;
}/* Reset and base styles */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 2rem;
font-size: 2.5rem;
font-weight: 300;
}
/* Add task form */
.add-task {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.add-task input {
flex: 1;
padding: 0.75rem;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.add-task input:focus {
outline: none;
border-color: #667eea;
}
.add-task button {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.add-task button:hover {
background: #5a6fd8;
}
/* Task counters */
.task-count, .completed-count {
color: #666;
font-size: 0.9rem;
margin: 1rem 0;
}
.completed-count {
color: #22c55e;
font-weight: 500;
}
/* Task list */
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: 1px solid #e1e5e9;
border-radius: 6px;
margin-bottom: 0.5rem;
transition: all 0.2s;
}
.task-item:hover {
border-color: #667eea;
background: #f8fafc;
}
.task-label {
display: flex;
align-items: center;
cursor: pointer;
flex: 1;
}
.task-label input[type="checkbox"] {
margin-right: 0.75rem;
transform: scale(1.2);
}
.task-label span.completed {
text-decoration: line-through;
color: #9ca3af;
}
.remove-btn {
background: #ef4444;
color: white;
border: none;
border-radius: 4px;
width: 2rem;
height: 2rem;
cursor: pointer;
font-size: 1.2rem;
transition: background 0.2s;
}
.remove-btn:hover {
background: #dc2626;
}
/* Empty state */
.empty-state {
text-align: center;
color: #9ca3af;
font-style: italic;
padding: 2rem;
}<input value.bind="newTaskText">
<span>${task.text}</span><button click.trigger="addTask()">Add Task</button>
<input keydown.trigger="addTaskOnEnter($event)"><p if.bind="tasks.length === 0">No tasks yet!</p><li repeat.for="task of tasks">
${task.text}
</li>get completedTaskCount(): number {
return this.tasks.filter(task => task.completed).length;
}<form-input
label.bind="label"
value.bind="message"
tooltip.bind="Did you know Aurelia syntax comes from an idea of an Angular community member? We greatly appreciate Angular and its community for this."
validation.bind="..."><label>${label}
<input value.bind tooltip.bind validation.bind min.bind max.bind>
</label><label>${label}
<input ...$attrs>
</label>@customElement({
...,
capture: true
})<input ...$attrs>export class FormInput {
@bindable label
}<form-input if.bind="needsComment" label.bind="label" value.bind="extraComment" class="form-control" style="background: var(--theme-purple)" tooltip="Hello, ${tooltip}">app.html
<input-field value.bind="message">
input-field.html
<my-input ...$attrs><!-- Spread bindables, then attributes, then explicit bindings -->
<input-field ...user ...$attrs id.bind="fieldId" class="form-control"><!-- The explicit value.bind will override any value from spreading -->
<input ...$attrs value.bind="explicitValue"><!-- Deep property access -->
<user-card ...user.profile.details>
<user-card ...user.addresses[0]>
<!-- Method calls and computed properties -->
<user-card ...user.getDetails()>
<user-card ...user.details | processUser>
<!-- For complex expressions, use the full syntax -->
<user-card ...$bindables="user.addresses.find(addr => addr.primary)"><!-- Only spread if user exists -->
<user-card ...$bindables="user || {}">
<!-- Spread different objects based on condition -->
<user-card ...$bindables="isAdmin ? adminUser : regularUser">
<!-- Combine with template controllers -->
<user-card if.bind="user" ...user><!-- These are equivalent -->
<input value.bind="value">
<input value.bind> <!-- Auto-infers 'value' property -->
<!-- Works with different binding commands -->
<input value.two-way="value">
<input value.two-way> <!-- Auto-infers 'value' property -->
<!-- Attribute binding -->
<div textcontent.bind="textcontent">
<div textcontent.bind> <!-- Auto-infers 'textcontent' property -->
<!-- Custom attributes -->
<div tooltip.bind="tooltip">
<div tooltip.bind> <!-- Auto-infers 'tooltip' property -->// Aurelia optimizes repeated spread operations
class UserCard {
@bindable user = { name: 'John', age: 30 };
updateUser() {
// If the same object reference is returned, bindings aren't recreated
this.user = this.user; // No rebinding
// New object reference triggers binding recreation
this.user = { ...this.user, age: 31 }; // Rebinding occurs
}
}<!-- Bindings are created once and reused when possible -->
<user-card ...user><!-- Safe spreading - handles null/undefined gracefully -->
<user-card ...user> <!-- Safe even if user is null/undefined -->
<user-card ...$bindables="user || {}"> <!-- Explicit fallback -->
<!-- Member access on null/undefined -->
<user-card ...user?.profile> <!-- Safe with optional chaining --><!-- These will be handled gracefully -->
<user-card ...undefined> <!-- No bindings created -->
<user-card ...nonExistentVar> <!-- No bindings created -->
<user-card ...user.invalid> <!-- No bindings created -->interface User {
name: string;
email: string;
age: number;
}
export class UserCard {
@bindable name: string;
@bindable email: string;
// age is not a bindable, so it won't be bound even if present in the object
}
const user: User = { name: 'John', email: '[email protected]', age: 30 };<!-- Only name and email will be bound based on component's @bindable properties -->
<user-card ...user>@customElement({
name: 'secure-input',
template: '<input ...$attrs>',
capture: attr => !attr.startsWith('on') // Exclude event handlers
})
export class SecureInput {
@bindable value: string;
}@customElement({
name: 'styled-input',
template: '<input ...$attrs>',
capture: attr => ['class', 'style', 'disabled'].includes(attr) // Only style-related
})
export class StyledInput {
@bindable value: string;
}<!-- Level 1: App uses form-group -->
<form-group title="User Info" ...validation>
<!-- Level 2: form-group uses input-field -->
<input-field label="Email" ...validation>
<!-- Level 3: input-field uses input -->
<input ...$attrs>
</input-field>
</form-group><!-- Template controllers are not captured -->
<input-field if.bind="showField" ...fieldProps>
<!-- Multiple template controllers -->
<input-field if.bind="showField" repeat.for="field of fields" ...field>// Base input component
export class BaseInput {
@bindable value: string;
@bindable placeholder: string;
@bindable disabled: boolean;
}
// Specialized email input
@customElement({
name: 'email-input',
template: '<base-input type="email" ...$attrs>',
capture: true
})
export class EmailInput {}
// Form field wrapper
@customElement({
name: 'form-field',
template: `
<div class="form-field">
<label if.bind="label">\${label}</label>
<div class="input-wrapper">
<div class="content-replaceable" replaceable part="input">
<input ...$attrs>
</div>
</div>
<div class="error" if.bind="error">\${error}</div>
</div>
`,
capture: true
})
export class FormField {
@bindable label: string;
@bindable error: string;
}<!-- Complex composition -->
<form-field label="Email Address" error.bind="emailError">
<email-input au-slot="input" value.bind="email" placeholder="Enter email">
</form-field>// Wrapper for third-party component
@customElement({
name: 'material-input',
template: '<mat-input ...$attrs>',
capture: attr => !attr.startsWith('au-') // Exclude Aurelia-specific attributes
})
export class MaterialInput {
@bindable value: string;
}export class DynamicForm {
@bindable fieldConfigs: FieldConfig[];
createField(config: FieldConfig) {
return {
component: config.component,
props: config.props
};
}
}<div repeat.for="config of fieldConfigs">
<compose
view-model.bind="config.component"
...$bindables="config.props">
</compose>
</div>// Good: Use spreading for configuration
interface ButtonConfig {
variant: 'primary' | 'secondary';
size: 'small' | 'medium' | 'large';
icon?: string;
}
const submitConfig: ButtonConfig = {
variant: 'primary',
size: 'medium',
icon: 'save'
};<custom-button ...submitConfig>Submit</custom-button>// Good: Build objects conditionally
const inputProps = {
value: userInput,
...(isRequired && { required: true }),
...(hasError && { 'aria-invalid': true }),
...(isDisabled && { disabled: true })
};<input ...$bindables="inputProps">// Good: Transform data before spreading
const transformedUser = {
displayName: user.fullName,
email: user.contactInfo.email,
isActive: user.status === 'active'
};<user-card ...transformedUser>// Good: Provide defaults
const defaultFieldProps = {
size: 'medium',
variant: 'outline'
};
const fieldProps = {
...defaultFieldProps,
...customProps
};<form-field ...$bindables="fieldProps"><!-- Vue: Reactivity with Proxy overhead -->
<template>
<div>
<input v-model="searchQuery" placeholder="Search users...">
<div v-if="loading">Loading...</div>
<user-card
v-for="user in filteredUsers"
:key="user.id"
:user="user"
@edit="handleEdit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const searchQuery = ref('')
const loading = ref(false)
const users = ref<User[]>([])
const filteredUsers = computed(() =>
users.value.filter(user =>
user.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
)
watch(searchQuery, async (newQuery) => {
if (newQuery.length > 2) {
loading.value = true
// Search logic
loading.value = false
}
})
</script>// Aurelia: Same simplicity, better performance
export class UserSearch {
searchQuery = '';
loading = false;
users: User[] = [];
// Computed properties work automatically - no wrapper needed
get filteredUsers() {
return this.users.filter(user =>
user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
// Watching is clean and intuitive
@watch('searchQuery')
async onSearchChange(newQuery: string) {
if (newQuery.length > 2) {
this.loading = true;
// Search logic
this.loading = false;
}
}
}<!-- Aurelia template: Clean HTML, no special directives -->
<div>
<input value.bind="searchQuery & debounce:300" placeholder="Search users...">
<div if.bind="loading">Loading...</div>
<user-card repeat.for="user of filteredUsers"
user.bind="user"
edit.bind="handleEdit">
</user-card>
</div><!-- Vue: TypeScript support is good but requires setup -->
<script setup lang="ts">
interface Props {
user: User
editable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
editable: false
})
const emit = defineEmits<{
edit: [user: User]
delete: [id: number]
}>()
</script>
// Aurelia: TypeScript-first, no setup needed
export class UserCard {
@bindable user: User;
@bindable editable = false;
// Events are just methods - no emit setup
edit() {
// Pass as callback via .bind, e.g. on-edit.bind="() => edit()"
}
delete() {
// Type-safe event handling
}
}<!-- Vue: Custom template syntax -->
<template>
<div :class="{ active: isActive, loading: isLoading }">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<div v-show="expanded">
<slot>Default content</slot>
</div>
</div>
</template>
<!-- Aurelia: Closer to web standards -->
<div class="card" active.class="isActive" loading.class="isLoading">
<au-slot name="header">
<h2>${title}</h2>
</au-slot>
<div show.bind="expanded">
<au-slot>Default content</au-slot>
</div>
</div><!-- Vue Single File Component -->
<template>
<div class="my-component">
<h1>{{ message }}</h1>
<button @click="updateMessage">Update</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('Hello Vue!')
const updateMessage = () => {
message.value = 'Updated!'
}
</script>
<style scoped>
.my-component {
padding: 20px;
border: 1px solid #ccc;
}
</style>// Aurelia: Similar structure, separate files (or inline)
export class MyComponent {
message = 'Hello Aurelia!';
updateMessage() {
this.message = 'Updated!';
}
}<!-- my-component.html -->
<div class="my-component">
<h1>${message}</h1>
<button click.trigger="updateMessage()">Update</button>
</div>/* my-component.css - automatically loaded! */
.my-component {
padding: 20px;
border: 1px solid #ccc;
}<!-- Vue: Composition API -->
<script setup>
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (newValue) => {
console.log(`Count changed to ${newValue}`)
})
</script>
// Aurelia: Plain JavaScript/TypeScript
export class Counter {
count = 0;
// Computed properties are just getters
get doubled() {
return this.count * 2;
}
// Watching is explicit and clear
@watch('count')
countChanged(newValue: number) {
console.log(`Count changed to ${newValue}`);
}
}<!-- Vue: Props and Emits -->
<script setup>
interface Props {
items: Item[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
itemSelected: [item: Item]
}>()
const selectItem = (item: Item) => {
emit('itemSelected', item)
}
</script>
// Aurelia: Bindable properties and callable methods
export class ItemList {
@bindable items: Item[];
// Just call this method from parent template
selectItem(item: Item) {
// Parent can bind to this with select-item.bind="(item) => handleSelection(item)"
}
}npx makes aurelia my-aurelia-app
cd my-aurelia-app
npm run dev<!-- Vue Todo Component -->
<template>
<div class="todo-app">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="Add a todo..."
>
<ul>
<li
v-for="todo in todos"
:key="todo.id"
:class="{ completed: todo.completed }"
>
<input
type="checkbox"
v-model="todo.completed"
>
<span>{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">Remove</button>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Todo {
id: number
text: string
completed: boolean
}
const newTodo = ref('')
const todos = ref<Todo[]>([])
let nextId = 1
const addTodo = () => {
if (newTodo.value.trim()) {
todos.value.push({
id: nextId++,
text: newTodo.value.trim(),
completed: false
})
newTodo.value = ''
}
}
const removeTodo = (id: number) => {
todos.value = todos.value.filter(todo => todo.id !== id)
}
</script>// Aurelia equivalent - cleaner and more intuitive
export class TodoApp {
newTodo = '';
todos: Todo[] = [];
private nextId = 1;
addTodo() {
if (this.newTodo.trim()) {
this.todos.push({
id: this.nextId++,
text: this.newTodo.trim(),
completed: false
});
this.newTodo = '';
}
}
removeTodo(id: number) {
this.todos = this.todos.filter(todo => todo.id !== id);
}
onEnterKey(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.addTodo();
}
}
}
interface Todo {
id: number;
text: string;
completed: boolean;
}<!-- todo-app.html -->
<div class="todo-app">
<input
value.bind="newTodo"
keydown.trigger="onEnterKey($event)"
placeholder="Add a todo..."
>
<ul>
<li repeat.for="todo of todos" completed.class="todo.completed">
<input type="checkbox" checked.bind="todo.completed">
<span>${todo.text}</span>
<button click.trigger="removeTodo(todo.id)">Remove</button>
</li>
</ul>
</div># Start your Aurelia journey
npx makes aurelia my-vue-to-aurelia-app
cd my-vue-to-aurelia-app
npm run devLearn how to manipulate the DOM from the usage-side of a custom element using the processContent hook.
// pseudo-code; `typeof TCustomElement` doesn't work in Generics form.
<TCustomElement>(this: typeof TCustomElement, node: INode, platform: IPlatform) => boolean | void;<my-element>
<foo></foo>
<bar></bar>
</my-element>import { customElement, INode, IPlatform } from '@aurelia/runtime-html';
// Use a standalone function
function processContent(node: INode, platform: IPlatform) { }
@customElement({ name: 'my-element', processContent })
export class MyElement { }
// ... or use a static method named 'processContent' (convention)
@customElement({ name: 'my-element' })
export class MyElement {
static processContent(node: INode, platform: IPlatform) { }
}import { customElement, INode, IPlatform, processContent } from '@aurelia/runtime-html';
// ...or a standalone method
function processContent(this: typeof MyElement, node: INode, platform: IPlatform) { }
@processContent(processContent)
export class MyElement {
}
// ...or the method-level decorator
export class MyElement {
@processContent()
static processContent(node: INode, platform: IPlatform) { }
}<!--tabs.html-->
<div class="header">
<au-slot name="header"></au-slot>
</div>
<div class="content">
<au-slot name="content"></au-slot>
</div><!--app.html-->
<tabs>
<tab header="Tab one">
<span>content for first tab.</span>
</tab>
<tab header="Tab two">
<span>content for second tab.</span>
</tab>
<tab header="Tab three">
<span>content for third tab.</span>
</tab>
</tabs>// tabs.ts
import { INode, IPlatform, processContent } from '@aurelia/runtime-html';
class Tabs {
@processContent()
public static processTabs(node: INode, p: IPlatform): boolean {
const el = node as Element;
// At first we prepare two templates that will provide the projections to the `header` and `content` slot respectively.
const headerTemplate = p.document.createElement('template');
headerTemplate.setAttribute('au-slot', 'header');
const contentTemplate = p.document.createElement('template');
contentTemplate.setAttribute('au-slot', 'content');
// Query the `<tab>` elements present in the `node`.
const tabs = toArray(el.querySelectorAll('tab'));
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
// Add header.
const header = p.document.createElement('button');
// Add a class binding to mark the active tab.
header.setAttribute('class.bind', `$host.activeTabId=='${i}'?'active':''`);
// Add a click delegate to activate a tab.
header.setAttribute('click.trigger', `$host.showTab('${i}')`);
header.appendChild(p.document.createTextNode(tab.getAttribute('header')));
headerTemplate.content.appendChild(header);
// Add content.
const content = p.document.createElement('div');
// Show the content if the tab is activated.
content.setAttribute('if.bind', `$host.activeTabId=='${i}'`);
content.append(...toArray(tab.childNodes));
contentTemplate.content.appendChild(content);
el.removeChild(tab);
}
// Set the first tab as the initial active tab.
el.setAttribute('active-tab-id', '0');
el.append(headerTemplate, contentTemplate);
}
@bindable public activeTabId: string;
public showTab(tabId: string) {
this.activeTabId = tabId;
}
}processContent(node: INode, p: IPlatform) {
const projection = p.document.createElement('template');
projection.setAttribute('au-slot', '');
const content = projection.content;
for (const child of toArray(node.childNodes)) {
if (!(child as Element).hasAttribute('au-slot')) {
content.append(child);
}
}
if (content.childElementCount > 0) {
node.appendChild(projection);
}
}import { bindable, IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { queueTask } from '@aurelia/runtime';
import { IPlatform } from '@aurelia/runtime-html';
export class DropdownMenu {
@bindable open = false;
@bindable position: 'left' | 'right' = 'left';
@bindable disabled = false;
private platform = resolve(IPlatform);
private element?: HTMLElement;
private triggerButton?: HTMLButtonElement;
private menuElement?: HTMLElement;
private clickOutsideHandler?: (e: MouseEvent) => void;
binding() {
this.setupClickOutsideHandler();
}
attaching(initiator: HTMLElement) {
this.element = initiator;
this.triggerButton = this.element.querySelector('[data-dropdown-trigger]') as HTMLButtonElement;
this.menuElement = this.element.querySelector('[data-dropdown-menu]') as HTMLElement;
}
detaching() {
this.removeClickOutsideListener();
}
toggle() {
if (this.disabled) return;
this.open = !this.open;
if (this.open) {
this.addClickOutsideListener();
this.focusFirstItem();
} else {
this.removeClickOutsideListener();
}
}
close() {
if (this.open) {
this.open = false;
this.removeClickOutsideListener();
this.triggerButton?.focus();
}
}
handleKeyDown(event: KeyboardEvent) {
if (this.disabled) return;
const { key } = event;
// Toggle on Enter or Space when trigger is focused
if ((key === 'Enter' || key === ' ') && document.activeElement === this.triggerButton) {
event.preventDefault();
this.toggle();
return;
}
// Close on Escape
if (key === 'Escape' && this.open) {
event.preventDefault();
this.close();
return;
}
// Arrow navigation when menu is open
if (this.open && (key === 'ArrowDown' || key === 'ArrowUp')) {
event.preventDefault();
this.navigateItems(key === 'ArrowDown' ? 1 : -1);
return;
}
// Activate item on Enter when focused
if (key === 'Enter' && this.open && document.activeElement?.hasAttribute('role')) {
event.preventDefault();
(document.activeElement as HTMLElement).click();
}
}
private navigateItems(direction: 1 | -1) {
if (!this.menuElement) return;
const items = Array.from(this.menuElement.querySelectorAll('[role="menuitem"]')) as HTMLElement[];
if (items.length === 0) return;
const currentIndex = items.findIndex(item => item === document.activeElement);
let nextIndex: number;
if (currentIndex === -1) {
// No item focused, focus first or last based on direction
nextIndex = direction === 1 ? 0 : items.length - 1;
} else {
// Move to next/previous item, wrapping around
nextIndex = (currentIndex + direction + items.length) % items.length;
}
items[nextIndex]?.focus();
}
private focusFirstItem() {
// Use tasksSettled to ensure DOM is updated
queueTask(() => {
const firstItem = this.menuElement?.querySelector('[role="menuitem"]') as HTMLElement;
firstItem?.focus();
});
}
private setupClickOutsideHandler() {
this.clickOutsideHandler = (event: MouseEvent) => {
const target = event.target as Node;
if (this.element && !this.element.contains(target)) {
this.close();
}
};
}
private addClickOutsideListener() {
if (this.clickOutsideHandler) {
// Use timeout to avoid immediate close from the same click that opened it
setTimeout(() => {
document.addEventListener('click', this.clickOutsideHandler!, true);
}, 0);
}
}
private removeClickOutsideListener() {
if (this.clickOutsideHandler) {
document.removeEventListener('click', this.clickOutsideHandler, true);
}
}
/**
* Call this when an item is selected to close the menu
*/
handleItemClick() {
this.close();
}
}<div
class="dropdown \${open ? 'dropdown--open' : ''} dropdown--\${position}"
keydown.trigger="handleKeyDown($event)"
ref="dropdownElement">
<!-- Trigger slot -->
<button
type="button"
class="dropdown__trigger"
click.trigger="toggle()"
aria-haspopup="true"
aria-expanded.bind="open"
disabled.bind="disabled"
data-dropdown-trigger>
<au-slot name="trigger">
<span>Menu</span>
<svg class="dropdown__icon" width="12" height="12" viewBox="0 0 12 12">
<path d="M6 9L1 4h10z" fill="currentColor"/>
</svg>
</au-slot>
</button>
<!-- Menu content -->
<div
class="dropdown__menu"
role="menu"
aria-hidden.bind="!open"
data-dropdown-menu
if.bind="open">
<au-slot>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 1</div>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 2</div>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 3</div>
</au-slot>
</div>
</div>.dropdown {
position: relative;
display: inline-block;
}
.dropdown__trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dropdown__trigger:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
}
.dropdown__trigger:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.dropdown__trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown__icon {
transition: transform 0.2s;
}
.dropdown--open .dropdown__icon {
transform: rotate(180deg);
}
.dropdown__menu {
position: absolute;
top: calc(100% + 4px);
min-width: 200px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 4px;
z-index: 1000;
animation: dropdown-slide-in 0.15s ease-out;
}
.dropdown--left .dropdown__menu {
left: 0;
}
.dropdown--right .dropdown__menu {
right: 0;
}
@keyframes dropdown-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown__menu [role="menuitem"] {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
outline: none;
}
.dropdown__menu [role="menuitem"]:hover,
.dropdown__menu [role="menuitem"]:focus {
background: #f3f4f6;
}
.dropdown__menu [role="menuitem"]:active {
background: #e5e7eb;
}
/* Divider */
.dropdown__divider {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}<dropdown-menu>
<div au-slot="trigger">
Actions
</div>
<div role="menuitem" tabindex="0">Edit</div>
<div role="menuitem" tabindex="0">Duplicate</div>
<div class="dropdown__divider"></div>
<div role="menuitem" tabindex="0">Delete</div>
</dropdown-menu><dropdown-menu position="right">
<button au-slot="trigger" class="icon-button">
<svg><!-- Settings icon --></svg>
</button>
<div role="menuitem" tabindex="0" click.trigger="openSettings()">
Settings
</div>
<div role="menuitem" tabindex="0" click.trigger="viewProfile()">
Profile
</div>
<div role="menuitem" tabindex="0" click.trigger="logout()">
Logout
</div>
</dropdown-menu>// your-component.ts
import { DropdownMenu } from './dropdown-menu';
export class YourComponent {
dropdownOpen = false;
openDropdown() {
this.dropdownOpen = true;
}
closeDropdown() {
this.dropdownOpen = false;
}
}<!-- your-component.html -->
<dropdown-menu open.bind="dropdownOpen">
<div role="menuitem" tabindex="0" click.trigger="performAction()">
Action
</div>
</dropdown-menu>
<button click.trigger="openDropdown()">Open Menu</button><dropdown-menu disabled.bind="isProcessing">
<div au-slot="trigger">
Actions \${isProcessing ? '(Processing...)' : ''}
</div>
<div role="menuitem" tabindex="0">Action 1</div>
<div role="menuitem" tabindex="0">Action 2</div>
</dropdown-menu>import { createFixture } from '@aurelia/testing';
import { DropdownMenu } from './dropdown-menu';
describe('DropdownMenu', () => {
it('toggles open/close on trigger click', async () => {
const { component, trigger, queryBy, stop } = await createFixture
.html`<dropdown-menu></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
expect(component.open).toBe(false);
expect(queryBy('[data-dropdown-menu]')).toBeNull();
// Click trigger to open
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Click trigger to close
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(false);
await stop(true);
});
it('closes when clicking outside', async () => {
const { component, trigger, stop } = await createFixture
.html`
<div>
<dropdown-menu></dropdown-menu>
<button id="outside">Outside</button>
</div>
`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Click outside
trigger.click('#outside');
// Wait for click handler
await new Promise(resolve => setTimeout(resolve, 10));
expect(component.open).toBe(false);
await stop(true);
});
it('closes on Escape key', async () => {
const { component, trigger, getBy, stop } = await createFixture
.html`<dropdown-menu></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Press Escape
trigger.keydown(getBy('.dropdown'), { key: 'Escape' });
expect(component.open).toBe(false);
await stop(true);
});
it('navigates items with arrow keys', async () => {
const { trigger, getBy, getAllBy, stop } = await createFixture
.html`
<dropdown-menu>
<div role="menuitem" tabindex="0">Item 1</div>
<div role="menuitem" tabindex="0">Item 2</div>
<div role="menuitem" tabindex="0">Item 3</div>
</dropdown-menu>
`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
const dropdown = getBy('.dropdown');
const items = getAllBy('[role="menuitem"]');
// First item should be focused
await new Promise(resolve => setTimeout(resolve, 10));
expect(document.activeElement).toBe(items[0]);
// Arrow down to second item
trigger.keydown(dropdown, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[1]);
// Arrow up back to first
trigger.keydown(dropdown, { key: 'ArrowUp' });
expect(document.activeElement).toBe(items[0]);
await stop(true);
});
it('does not open when disabled', async () => {
const { component, trigger, stop } = await createFixture
.html`<dropdown-menu disabled.bind="true"></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(false);
await stop(true);
});
});<div role="menuitem" tabindex="0" class="menu-item">
<svg class="menu-item__icon"><!-- Icon --></svg>
<span>Edit</span>
</div><dropdown-menu>
<div role="menuitem" tabindex="0">Item 1</div>
<dropdown-menu position="right">
<div au-slot="trigger" role="menuitem" tabindex="0">
More Actions →
</div>
<div role="menuitem" tabindex="0">Sub Item 1</div>
<div role="menuitem" tabindex="0">Sub Item 2</div>
</dropdown-menu>
</dropdown-menu>export class SearchableDropdown {
@bindable items: any[] = [];
searchTerm = '';
get filteredItems() {
return this.items.filter(item =>
item.label.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
}import { computePosition, flip, shift } from '@floating-ui/dom';
async positionMenu() {
const { x, y } = await computePosition(this.triggerButton!, this.menuElement!, {
middleware: [flip(), shift({ padding: 8 })]
});
Object.assign(this.menuElement!.style, {
left: `${x}px`,
top: `${y}px`
});
}Angular developers: Keep the best parts (DI, TypeScript, CLI) while eliminating the complexity and improving performance.
Advanced patterns for building custom attributes in Aurelia 2, including template controllers, complex bindings, and performance optimization.
// Angular: Heavy ceremony and boilerplate
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-user-search',
template: `
<div>
<input
[value]="searchQuery"
(input)="onSearchInput($event)"
placeholder="Search users..."
>
<div *ngIf="loading">Loading...</div>
<app-user-card
*ngFor="let user of filteredUsers; trackBy: trackByUserId"
[user]="user"
(userEdit)="onUserEdit($event)"
></app-user-card>
</div>
`
})
export class UserSearchComponent implements OnInit, OnDestroy {
@Input() users: User[] = [];
@Output() userEdit = new EventEmitter<User>();
searchQuery = '';
filteredUsers: User[] = [];
loading = false;
private destroy$ = new Subject<void>();
private searchSubject = new Subject<string>();
ngOnInit() {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe(query => this.performSearch(query));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
onSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
this.searchQuery = target.value;
this.searchSubject.next(this.searchQuery);
}
trackByUserId(index: number, user: User): number {
return user.id;
}
private async performSearch(query: string) {
if (query.length > 2) {
this.loading = true;
// Search logic
this.loading = false;
} else {
this.filteredUsers = [];
}
}
onUserEdit(user: User) {
this.userEdit.emit(user);
}
}
// Aurelia: Clean, intuitive code
export class UserSearch {
@bindable users: User[];
@bindable userEdit: (user: User) => void;
searchQuery = '';
loading = false;
// Computed properties work automatically
get filteredUsers() {
if (this.searchQuery.length < 3) return [];
return this.users.filter(user =>
user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
// Simple debounced search
@watch('searchQuery')
async searchChanged(newQuery: string) {
if (newQuery.length > 2) {
this.loading = true;
// Search logic
this.loading = false;
}
}
}<!-- Aurelia template: Clean HTML -->
<div>
<input value.bind="searchQuery & debounce:300" placeholder="Search users...">
<div if.bind="loading">Loading...</div>
<user-card repeat.for="user of filteredUsers"
user.bind="user"
user-edit.bind="() => userEdit(user)">
</user-card>
</div>// Angular: Complex DI with decorators and modules
import { Injectable, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class UserService {
constructor(
private http: HttpClient,
@Inject('API_URL') private apiUrl: string
) {}
}
@NgModule({
providers: [
{ provide: 'API_URL', useValue: 'https://api.example.com' },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
})
export class AppModule {}
// Aurelia: Simple, powerful DI
export const IUserService = DI.createInterface<IUserService>(
'IUserService',
x => x.singleton(UserService)
);
export class UserService {
private http = resolve(IHttpClient);
private config = resolve(IApiConfig);
// That's it - no modules, no complex setup
}
// Use anywhere
export class UserList {
private userService = resolve(IUserService);
// Clean, type-safe injection
}// Angular: Lots of ceremony for type safety
interface User {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-user-detail',
template: `
<div *ngIf="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<button (click)="editUser()">Edit</button>
</div>
`
})
export class UserDetailComponent {
@Input() user: User | null = null;
@Output() edit = new EventEmitter<User>();
editUser() {
if (this.user) {
this.edit.emit(this.user);
}
}
}
// Aurelia: TypeScript-first design
export class UserDetail {
@bindable user: User | null = null;
@bindable edit: (user: User) => void;
editUser() {
if (this.user) {
this.edit(this.user);
}
}
}<!-- Aurelia template with automatic type checking -->
<div if.bind="user">
<h2>${user.name}</h2>
<p>${user.email}</p>
<button click.trigger="editUser()">Edit</button>
</div>// Angular Component
@Component({
selector: 'app-todo-list',
template: `
<div class="todo-app">
<input
[(ngModel)]="newTodo"
(keyup.enter)="addTodo()"
placeholder="Add todo..."
>
<ul>
<li *ngFor="let todo of todos; trackBy: trackByTodoId"
[class.completed]="todo.completed">
<input
type="checkbox"
[(ngModel)]="todo.completed"
>
<span>{{ todo.text }}</span>
<button (click)="deleteTodo(todo.id)">Delete</button>
</li>
</ul>
</div>
`,
styleUrls: ['./todo-list.component.css']
})
export class TodoListComponent {
@Input() todos: Todo[] = [];
@Output() todoAdded = new EventEmitter<Todo>();
@Output() todoDeleted = new EventEmitter<number>();
newTodo = '';
private nextId = 1;
addTodo() {
if (this.newTodo.trim()) {
const todo: Todo = {
id: this.nextId++,
text: this.newTodo.trim(),
completed: false
};
this.todoAdded.emit(todo);
this.newTodo = '';
}
}
deleteTodo(id: number) {
this.todoDeleted.emit(id);
}
trackByTodoId(index: number, todo: Todo): number {
return todo.id;
}
}
// Aurelia Component - much cleaner
export class TodoList {
@bindable todos: Todo[] = [];
@bindable todoAdded: (todo: Todo) => void;
@bindable todoDeleted: (id: number) => void;
newTodo = '';
private nextId = 1;
addTodo() {
if (this.newTodo.trim()) {
const todo: Todo = {
id: this.nextId++,
text: this.newTodo.trim(),
completed: false
};
this.todoAdded(todo);
this.newTodo = '';
}
}
deleteTodo(id: number) {
this.todoDeleted(id);
}
onEnterKey(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.addTodo();
}
}
}<!-- todo-list.html - clean, readable -->
<div class="todo-app">
<input
value.bind="newTodo"
keydown.trigger="onEnterKey($event)"
placeholder="Add todo..."
>
<ul>
<li repeat.for="todo of todos" completed.class="todo.completed">
<input type="checkbox" checked.bind="todo.completed">
<span>${todo.text}</span>
<button click.trigger="deleteTodo(todo.id)">Delete</button>
</li>
</ul>
</div>// Angular Service
@Injectable({
providedIn: 'root'
})
export class DataService {
constructor(
private http: HttpClient,
@Inject('API_CONFIG') private config: ApiConfig
) {}
async getUsers(): Promise<User[]> {
return this.http.get<User[]>(`${this.config.baseUrl}/users`).toPromise();
}
}
// Aurelia Service - cleaner and more flexible
export const IDataService = DI.createInterface<IDataService>(
'IDataService',
x => x.singleton(DataService)
);
export class DataService {
private http = resolve(IHttpClient);
private config = resolve(IApiConfig);
async getUsers(): Promise<User[]> {
return this.http.get(`${this.config.baseUrl}/users`);
}
}npx makes aurelia my-aurelia-app
cd my-aurelia-app
npm run dev// Angular
@Component({
selector: 'app-user-profile',
template: `
<div class="profile" [class.editing]="isEditing">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<button *ngIf="!isEditing" (click)="startEdit()">Edit</button>
<div *ngIf="isEditing">
<input [(ngModel)]="editName" placeholder="Name">
<input [(ngModel)]="editEmail" placeholder="Email">
<button (click)="saveChanges()">Save</button>
<button (click)="cancelEdit()">Cancel</button>
</div>
</div>
`
})
export class UserProfileComponent {
@Input() user: User;
@Output() userUpdated = new EventEmitter<User>();
isEditing = false;
editName = '';
editEmail = '';
startEdit() {
this.isEditing = true;
this.editName = this.user.name;
this.editEmail = this.user.email;
}
saveChanges() {
const updatedUser = { ...this.user, name: this.editName, email: this.editEmail };
this.userUpdated.emit(updatedUser);
this.isEditing = false;
}
cancelEdit() {
this.isEditing = false;
}
}
// Aurelia - same functionality, cleaner code
export class UserProfile {
@bindable user: User;
@bindable userUpdated: (user: User) => void;
isEditing = false;
editName = '';
editEmail = '';
startEdit() {
this.isEditing = true;
this.editName = this.user.name;
this.editEmail = this.user.email;
}
saveChanges() {
const updatedUser = { ...this.user, name: this.editName, email: this.editEmail };
this.userUpdated(updatedUser);
this.isEditing = false;
}
cancelEdit() {
this.isEditing = false;
}
}<!-- user-profile.html -->
<div class="profile" editing.class="isEditing">
<h2>${user.name}</h2>
<p>${user.email}</p>
<button if.bind="!isEditing" click.trigger="startEdit()">Edit</button>
<div if.bind="isEditing">
<input value.bind="editName" placeholder="Name">
<input value.bind="editEmail" placeholder="Email">
<button click.trigger="saveChanges()">Save</button>
<button click.trigger="cancelEdit()">Cancel</button>
</div>
</div># Start your Aurelia journey
npx makes aurelia my-angular-to-aurelia-app
cd my-angular-to-aurelia-app
npm run devimport { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'my-controller',
isTemplateController: true,
bindables: ['value']
})
export class MyController {
public readonly $controller!: ICustomAttributeController<this>;
private readonly factory = resolve(IViewFactory);
private readonly location = resolve(IRenderLocation);
private view?: ISyntheticView;
public value: unknown;
public valueChanged(newValue: unknown): void {
this.updateView(newValue);
}
private updateView(show: boolean): void {
if (show && !this.view) {
this.view = this.factory.create().setLocation(this.location);
this.view.activate(this.view, this.$controller, this.$controller.scope);
} else if (!show && this.view) {
this.view.deactivate(this.view, this.$controller);
this.view = undefined;
}
}
public attaching(): void {
if (this.value) {
this.updateView(true);
}
}
public detaching(): void {
if (this.view) {
this.view.deactivate(this.view, this.$controller);
this.view = undefined;
}
}
}<div my-controller.bind="condition">
This content is conditionally rendered
</div>import { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
interface IPermissionService {
hasPermission(permission: string): boolean;
hasAnyPermission(permissions: string[]): boolean;
}
@customAttribute({
name: 'show-if-permitted',
isTemplateController: true,
bindables: ['permission', 'anyOf']
})
export class ShowIfPermitted {
public readonly $controller!: ICustomAttributeController<this>;
private readonly factory = resolve(IViewFactory);
private readonly location = resolve(IRenderLocation);
private readonly permissionService = resolve(IPermissionService);
private view?: ISyntheticView;
public permission?: string;
public anyOf?: string[];
public permissionChanged(): void {
this.updateVisibility();
}
public anyOfChanged(): void {
this.updateVisibility();
}
private updateVisibility(): void {
const hasPermission = this.permission
? this.permissionService.hasPermission(this.permission)
: this.anyOf
? this.permissionService.hasAnyPermission(this.anyOf)
: false;
if (hasPermission && !this.view) {
this.view = this.factory.create().setLocation(this.location);
this.view.activate(this.view, this.$controller, this.$controller.scope);
} else if (!hasPermission && this.view) {
this.view.deactivate(this.view, this.$controller);
this.view = undefined;
}
}
public attaching(): void {
this.updateVisibility();
}
public detaching(): void {
if (this.view) {
this.view.deactivate(this.view, this.$controller);
this.view = undefined;
}
}
}<div show-if-permitted.bind="'admin'">
Admin-only content
</div>
<div show-if-permitted any-of.bind="['user', 'moderator']">
User or moderator content
</div>import { customAttribute, ICustomAttributeController, IViewFactory, IRenderLocation, ISyntheticView } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'loading-state',
isTemplateController: true,
bindables: ['isLoading', 'cache']
})
export class LoadingState {
public readonly $controller!: ICustomAttributeController<this>;
private readonly factory = resolve(IViewFactory);
private readonly location = resolve(IRenderLocation);
private view?: ISyntheticView;
private cachedView?: ISyntheticView;
public isLoading: boolean = false;
public cache: boolean = true;
public isLoadingChanged(newValue: boolean): void {
this.updateView(newValue);
}
private updateView(isLoading: boolean): void {
if (!isLoading && !this.view) {
// Show content
if (this.cache && this.cachedView) {
this.view = this.cachedView;
} else {
this.view = this.factory.create().setLocation(this.location);
if (this.cache) {
this.cachedView = this.view;
}
}
this.view.activate(this.view, this.$controller, this.$controller.scope);
} else if (isLoading && this.view) {
// Hide content
this.view.deactivate(this.view, this.$controller);
if (!this.cache) {
this.view = undefined;
} else {
this.view = undefined; // Keep cached view
}
}
}
public attaching(): void {
this.updateView(this.isLoading);
}
public detaching(): void {
if (this.view) {
this.view.deactivate(this.view, this.$controller);
this.view = undefined;
}
if (this.cachedView) {
this.cachedView = undefined;
}
}
}<div loading-state.bind="isLoading" cache.bind="true">
<p>This content is hidden while loading</p>
</div>import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'auto-save',
bindables: ['value', 'debounce']
})
export class AutoSave {
private element = resolve(INode) as HTMLInputElement;
private debounceTimer?: number;
public value: string = '';
public debounce: number = 500;
public valueChanged(newValue: string): void {
if (this.element.value !== newValue) {
this.element.value = newValue;
}
}
public attached(): void {
this.element.addEventListener('input', this.handleInput);
this.element.addEventListener('blur', this.handleBlur);
}
public detaching(): void {
this.element.removeEventListener('input', this.handleInput);
this.element.removeEventListener('blur', this.handleBlur);
this.clearTimer();
}
private handleInput = (): void => {
this.clearTimer();
this.debounceTimer = window.setTimeout(() => {
this.updateValue();
}, this.debounce);
};
private handleBlur = (): void => {
this.clearTimer();
this.updateValue();
};
private updateValue(): void {
const newValue = this.element.value;
if (this.value !== newValue) {
this.value = newValue;
}
}
private clearTimer(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
}
}
}<input auto-save.bind="document.title" debounce.bind="1000">import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'slider-range',
bindables: ['min', 'max', 'step', 'value']
})
export class SliderRange {
private element = resolve(INode) as HTMLInputElement;
public min: number = 0;
public max: number = 100;
public step: number = 1;
public value: number = 0;
public minChanged(newValue: number): void {
this.element.min = String(newValue);
this.validateValue();
}
public maxChanged(newValue: number): void {
this.element.max = String(newValue);
this.validateValue();
}
public stepChanged(newValue: number): void {
this.element.step = String(newValue);
}
public valueChanged(newValue: number): void {
const validValue = this.clampValue(newValue);
if (this.element.value !== String(validValue)) {
this.element.value = String(validValue);
}
}
public attached(): void {
this.element.type = 'range';
this.element.addEventListener('input', this.handleInput);
this.element.addEventListener('change', this.handleChange);
this.updateElement();
}
public detaching(): void {
this.element.removeEventListener('input', this.handleInput);
this.element.removeEventListener('change', this.handleChange);
}
private handleInput = (): void => {
this.value = Number(this.element.value);
};
private handleChange = (): void => {
this.value = Number(this.element.value);
};
private validateValue(): void {
const clampedValue = this.clampValue(this.value);
if (clampedValue !== this.value) {
this.value = clampedValue;
}
}
private clampValue(value: number): number {
return Math.max(this.min, Math.min(this.max, value));
}
private updateElement(): void {
this.element.min = String(this.min);
this.element.max = String(this.max);
this.element.step = String(this.step);
this.element.value = String(this.clampValue(this.value));
}
}<input slider-range min.bind="0" max.bind="100" step.bind="5" value.bind="currentValue">import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'lazy-load',
bindables: ['src', 'placeholder']
})
export class LazyLoad {
private element = resolve(INode) as HTMLImageElement;
private observer?: IntersectionObserver;
public src: string = '';
public placeholder: string = '';
public attached(): void {
this.element.src = this.placeholder;
this.setupIntersectionObserver();
}
public detaching(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
}
private setupIntersectionObserver(): void {
if (!('IntersectionObserver' in window)) {
// Fallback for older browsers
this.loadImage();
return;
}
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
});
},
{ threshold: 0.1 }
);
this.observer.observe(this.element);
}
private loadImage(): void {
if (this.src) {
this.element.src = this.src;
}
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'batch-class',
bindables: ['classes']
})
export class BatchClass {
private element = resolve(INode) as HTMLElement;
private scheduledUpdate = false;
private appliedClasses = new Set<string>();
public classes: Record<string, boolean> = {};
public classesChanged(): void {
this.scheduleUpdate();
}
private scheduleUpdate(): void {
if (!this.scheduledUpdate) {
this.scheduledUpdate = true;
requestAnimationFrame(() => {
this.updateClasses();
this.scheduledUpdate = false;
});
}
}
private updateClasses(): void {
const newClasses = new Set<string>();
// Collect classes that should be applied
for (const [className, shouldApply] of Object.entries(this.classes)) {
if (shouldApply) {
newClasses.add(className);
}
}
// Remove classes that are no longer needed
for (const className of this.appliedClasses) {
if (!newClasses.has(className)) {
this.element.classList.remove(className);
}
}
// Add new classes
for (const className of newClasses) {
if (!this.appliedClasses.has(className)) {
this.element.classList.add(className);
}
}
this.appliedClasses = newClasses;
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve, ILogger } from '@aurelia/kernel';
@customAttribute({
name: 'safe-transform',
bindables: ['transform', 'fallback']
})
export class SafeTransform {
private element = resolve(INode) as HTMLElement;
private logger = resolve(ILogger);
public transform: string = '';
public fallback: string = '';
public transformChanged(newValue: string): void {
this.applyTransform(newValue);
}
private applyTransform(transform: string): void {
try {
this.element.style.transform = transform;
} catch (error) {
this.logger.warn(`Invalid transform "${transform}":`, error);
this.element.style.transform = this.fallback;
}
}
public attached(): void {
this.applyTransform(this.transform);
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'safe-html',
bindables: ['content', 'allowedTags']
})
export class SafeHtml {
private element = resolve(INode) as HTMLElement;
public content: string = '';
public allowedTags: string[] = ['b', 'i', 'em', 'strong', 'p', 'br'];
public contentChanged(newValue: string): void {
this.updateContent(newValue);
}
private updateContent(content: string): void {
const sanitized = this.sanitizeHtml(content);
this.element.innerHTML = sanitized;
}
private sanitizeHtml(html: string): string {
// Simple sanitization - in production, use a proper library like DOMPurify
const div = document.createElement('div');
div.innerHTML = html;
// Remove all elements not in allowed tags
const elements = div.querySelectorAll('*');
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (!this.allowedTags.includes(element.tagName.toLowerCase())) {
element.remove();
}
}
return div.innerHTML;
}
}import { TestContext } from '@aurelia/testing';
import { MyController } from './my-controller';
describe('MyController', () => {
let ctx: TestContext;
beforeEach(() => {
ctx = TestContext.create();
});
afterEach(() => {
ctx.dispose();
});
it('should show content when value is true', async () => {
const { component, startPromise, tearDown } = ctx.createFixture(
`<div my-controller.bind="showContent">Content</div>`,
class {
showContent = true;
}
);
await startPromise;
expect(component.textContent).toContain('Content');
await tearDown();
});
it('should hide content when value is false', async () => {
const { component, startPromise, tearDown } = ctx.createFixture(
`<div my-controller.bind="showContent">Content</div>`,
class {
showContent = false;
}
);
await startPromise;
expect(component.textContent).not.toContain('Content');
await tearDown();
});
});public detaching(): void {
if (this.observer) {
this.observer.disconnect();
}
if (this.subscription) {
this.subscription.dispose();
}
}// src/components/search-autocomplete.ts
import { bindable, INode, IPlatform } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export interface SearchResult {
id: string | number;
title: string;
description?: string;
image?: string;
category?: string;
}
export class SearchAutocomplete {
@bindable placeholder = 'Search...';
@bindable minLength = 2;
@bindable debounceMs = 300;
@bindable maxResults = 10;
@bindable onSelect: (result: SearchResult) => void;
@bindable onSearch: (query: string) => Promise<SearchResult[]>;
private query = '';
private results: SearchResult[] = [];
private isOpen = false;
private isLoading = false;
private selectedIndex = -1;
private searchTimeout: any = null;
private inputElement?: HTMLInputElement;
private dropdownElement?: HTMLElement;
private clickOutsideListener?: (e: MouseEvent) => void;
private readonly platform = resolve(IPlatform);
private readonly element = resolve(INode);
attached() {
// Listen for clicks outside to close dropdown
this.clickOutsideListener = (e: MouseEvent) => {
if (!this.element.contains(e.target as Node)) {
this.close();
}
};
this.platform.document?.addEventListener('click', this.clickOutsideListener);
}
detaching() {
// Clean up event listener
if (this.clickOutsideListener) {
this.platform.document?.removeEventListener('click', this.clickOutsideListener);
}
// Clean up timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
private async performSearch() {
if (!this.query || this.query.length < this.minLength) {
this.results = [];
this.isOpen = false;
return;
}
this.isLoading = true;
this.isOpen = true;
try {
if (this.onSearch) {
// Use custom search function
this.results = await this.onSearch(this.query);
} else {
// Use default search (for demo purposes)
this.results = await this.defaultSearch(this.query);
}
// Limit results
this.results = this.results.slice(0, this.maxResults);
// Reset selection
this.selectedIndex = -1;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.isLoading = false;
}
}
// Default search implementation (replace with real API)
private async defaultSearch(query: string): Promise<SearchResult[]> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
const mockData: SearchResult[] = [
{ id: 1, title: 'Getting Started with Aurelia', category: 'Tutorial' },
{ id: 2, title: 'Advanced Routing', category: 'Guide' },
{ id: 3, title: 'Dependency Injection', category: 'Concept' },
{ id: 4, title: 'Template Syntax', category: 'Reference' },
{ id: 5, title: 'Validation Plugin', category: 'Plugin' },
];
return mockData.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.category?.toLowerCase().includes(query.toLowerCase())
);
}
queryChanged(newValue: string, oldValue: string) {
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Debounce the search
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, this.debounceMs);
}
handleKeydown(event: KeyboardEvent) {
if (!this.isOpen || this.results.length === 0) {
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this.scrollToSelected();
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
this.scrollToSelected();
break;
case 'Enter':
event.preventDefault();
if (this.selectedIndex >= 0) {
this.selectResult(this.results[this.selectedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.close();
break;
}
}
private scrollToSelected() {
if (!this.dropdownElement || this.selectedIndex < 0) {
return;
}
const selectedElement = this.dropdownElement.querySelector(
`.autocomplete-item[data-index="${this.selectedIndex}"]`
) as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
selectResult(result: SearchResult) {
if (this.onSelect) {
this.onSelect(result);
}
// Set input to selected title
this.query = result.title;
// Close dropdown
this.close();
}
close() {
this.isOpen = false;
this.selectedIndex = -1;
}
highlightMatch(text: string, query: string): string {
if (!query) return text;
const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
get showEmpty(): boolean {
return this.isOpen &&
!this.isLoading &&
this.query.length >= this.minLength &&
this.results.length === 0;
}
}<!-- src/components/search-autocomplete.html -->
<div class="autocomplete">
<!-- Search input -->
<div class="autocomplete-input-wrapper">
<input
ref="inputElement"
type="text"
value.bind="query"
keydown.trigger="handleKeydown($event)"
placeholder.bind="placeholder"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-expanded.bind="isOpen"
aria-controls="autocomplete-dropdown"
aria-activedescendant.bind="selectedIndex >= 0 ? `result-${selectedIndex}` : undefined"
class="autocomplete-input">
<!-- Loading spinner -->
<div if.bind="isLoading" class="autocomplete-spinner">
<span class="spinner"></span>
</div>
<!-- Clear button -->
<button
if.bind="query && !isLoading"
type="button"
click.trigger="query = ''; close()"
class="autocomplete-clear"
aria-label="Clear search">
×
</button>
</div>
<!-- Dropdown -->
<div
if.bind="isOpen"
ref="dropdownElement"
id="autocomplete-dropdown"
role="listbox"
class="autocomplete-dropdown">
<!-- Results -->
<div
repeat.for="result of results"
data-index.bind="$index"
id="result-${$index}"
role="option"
aria-selected.bind="selectedIndex === $index"
click.trigger="selectResult(result)"
class="autocomplete-item ${selectedIndex === $index ? 'selected' : ''}">
<!-- Image (if provided) -->
<img
if.bind="result.image"
src.bind="result.image"
alt=""
class="autocomplete-item-image">
<div class="autocomplete-item-content">
<div
class="autocomplete-item-title"
innerhtml.bind="highlightMatch(result.title, query)"></div>
<div
if.bind="result.description"
class="autocomplete-item-description">
${result.description}
</div>
<div
if.bind="result.category"
class="autocomplete-item-category">
${result.category}
</div>
</div>
</div>
<!-- Empty state -->
<div if.bind="showEmpty" class="autocomplete-empty">
No results found for "${query}"
</div>
</div>
</div>.autocomplete {
position: relative;
width: 100%;
}
.autocomplete-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.autocomplete-input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.autocomplete-input:focus {
border-color: #2196f3;
}
.autocomplete-spinner {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #e0e0e0;
border-top-color: #2196f3;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.autocomplete-clear {
position: absolute;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 24px;
height: 24px;
line-height: 1;
border-radius: 50%;
transition: background-color 0.2s;
}
.autocomplete-clear:hover {
background-color: #f5f5f5;
color: #333;
}
.autocomplete-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
max-height: 400px;
overflow-y: auto;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.autocomplete-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.15s;
border-bottom: 1px solid #f5f5f5;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background-color: #f5f5f5;
}
.autocomplete-item-image {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.autocomplete-item-content {
flex-grow: 1;
min-width: 0;
}
.autocomplete-item-title {
font-weight: 500;
color: #333;
margin-bottom: 0.25rem;
}
.autocomplete-item-title mark {
background-color: #ffeb3b;
padding: 0 2px;
border-radius: 2px;
}
.autocomplete-item-description {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.autocomplete-item-category {
font-size: 0.75rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.autocomplete-empty {
padding: 2rem 1rem;
text-align: center;
color: #999;
}// src/pages/search-page.ts
import { IRouter } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
export class SearchPage {
private readonly router = resolve(IRouter);
async searchProducts(query: string) {
const response = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`);
return response.json();
}
handleSelect(result: any) {
console.log('Selected:', result);
this.router.load(`products/${result.id}`);
}
}<!-- src/pages/search-page.html -->
<div class="search-page">
<h1>Search Products</h1>
<search-autocomplete
placeholder="Search for products..."
min-length.bind="2"
debounce-ms.bind="300"
max-results.bind="10"
on-search.bind="searchProducts"
on-select.bind="handleSelect">
</search-autocomplete>
</div>private recentSearches: string[] = [];
attached() {
// Load from localStorage
const stored = localStorage.getItem('recent-searches');
if (stored) {
this.recentSearches = JSON.parse(stored);
}
}
selectResult(result: SearchResult) {
// Save to recent searches
this.recentSearches = [
result.title,
...this.recentSearches.filter(s => s !== result.title)
].slice(0, 5);
localStorage.setItem('recent-searches', JSON.stringify(this.recentSearches));
// ... rest of implementation
}get groupedResults() {
const groups = new Map<string, SearchResult[]>();
this.results.forEach(result => {
const category = result.category || 'Other';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(result);
});
return Array.from(groups.entries());
}<div repeat.for="[category, items] of groupedResults">
<div class="autocomplete-group-header">${category}</div>
<div repeat.for="item of items" class="autocomplete-item">
<!-- item content -->
</div>
</div>handleScroll(event: Event) {
const element = event.target as HTMLElement;
const bottom = element.scrollHeight - element.scrollTop === element.clientHeight;
if (bottom && !this.isLoading && this.hasMoreResults) {
this.loadMore();
}
}handleClick(event: MouseEvent) {
console.log('Click handler called', event);
// Your logic here
}handleEvent(event: Event) {
console.log('Event details:', {
type: event.type,
target: event.target,
currentTarget: event.currentTarget
});
}<element event.command="methodName(argument1, argument2, ...)"><button click.trigger="handleClick()">Click Me</button><!-- These two lines are identical -->
<button click.trigger="save()">Save</button>
<button @click="save()">Save</button>
<!-- Modifiers work the same way -->
<button @click:ctrl+enter="send()">Send (Ctrl + Enter)</button><button click.trigger="handleClick($event)">Click Me</button>export class MyViewModel {
handleClick(event: MouseEvent) {
console.log('Button clicked!', event);
// Access event properties like event.target, event.clientX, etc.
}
}<button click.trigger="removeItem(item.id, $event)">Remove Item</button>export class MyViewModel {
removeItem(itemId: number, event: MouseEvent) {
console.log(`Removing item with ID: ${itemId}`, event);
// Logic to remove the item
}
}<button click.trigger="submitForm()">Submit</button>
<a href="#" click.trigger="openModal()">Learn More</a><input type="text" input.trigger="updateSearchQuery($event.target.value)" placeholder="Search..." /><select change.trigger="selectTheme($event.target.value)">
<option value="light">Light Theme</option>
<option value="dark">Dark Theme</option>
</select><div mouseover.trigger="highlight()" mouseout.trigger="unhighlight()">Hover Me</div><input type="text" keydown.trigger="handleKeyDown($event)" /><div mousemove.trigger="trackMouse($event) & throttle:50">Move mouse here</div><input type="text" input.trigger="searchQuery($event.target.value) & debounce:300" placeholder="Search" /><input input.trigger="search($event.target.value) & debounce:300:'immediate'">
<button click.trigger="signaler.dispatchSignal('immediate')">Search Now</button><my-custom-element data-loaded.trigger="handleDataLoaded($event)"></my-custom-element><div click.trigger="divClicked() & self">
<p>Clicking here will trigger divClicked</p>
<button click.trigger="buttonClicked()">Clicking button will NOT trigger divClicked</button>
</div><input type="checkbox" checked.bind="isAgreed" change.trigger="agreementChanged()" id="agreementCheckbox">
<label for="agreementCheckbox">I agree to the terms</label>export class MyViewModel {
isAgreed = false;
agreementChanged() {
console.log('Agreement status changed:', this.isAgreed);
// Perform actions based on checkbox state
}
}<input type="text" keydown.trigger="handleKeyDown($event)" placeholder="Type here">export class MyViewModel {
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
console.log('Enter key pressed!');
// Perform action on Enter key press (e.g., submit form)
event.preventDefault(); // Prevent default form submission if inside a form
} else if (event.key === 'Escape') {
console.log('Escape key pressed!');
// Handle Escape key press (e.g., clear input)
}
// ... handle other keys as needed
}
}<ul click.trigger="listItemClicked($event)">
<li repeat.for="item of items" data-item-id="${item.id}">${item.name}</li>
</ul>export class MyViewModel {
items = [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }];
listItemClicked(event: Event) {
const target = event.target as HTMLElement;
if (target.tagName === 'LI') {
const itemId = target.dataset.itemId;
console.log(`List item clicked, ID: ${itemId}`);
// Logic to handle click on list item with ID itemId
}
}
}import { bindable, customElement, resolve } from 'aurelia';
import { INode } from '@aurelia/runtime-html';
@customElement({ name: 'my-button', template: `<button click.trigger="handleClick()">\${label}</button>` })
export class MyButton {
private element = resolve(INode) as HTMLElement;
@bindable label = 'Click Me';
handleClick() {
this.element.dispatchEvent(new CustomEvent('button-clicked', {
bubbles: true, // Allow event to bubble up
detail: { message: 'Button with label "' + this.label + '" was clicked' }
}));
}
}<my-button label="Action Button" button-clicked.trigger="handleButtonClick($event)"></my-button>export class ParentViewModel {
handleButtonClick(event: CustomEvent) {
console.log('Custom event "button-clicked" received:', event.detail.message);
// Handle the custom event
}
}<input type="text" input.trigger="autocomplete($event.target.value) & debounce:500" placeholder="Start typing..." />
<ul if.bind="suggestions.length">
<li repeat.for="suggestion of suggestions">${suggestion}</li>
</ul>export class MyViewModel {
searchQuery = '';
suggestions = [];
autocomplete(query: string) {
this.searchQuery = query;
if (query.length > 2) {
// Simulate API call for suggestions (replace with actual API call)
setTimeout(() => {
this.suggestions = [`${query} suggestion 1`, `${query} suggestion 2`, `${query} suggestion 3`];
}, 300);
} else {
this.suggestions = [];
}
}
}<element event.trigger[:modifier]="methodName()"><!-- Submit only on Ctrl + Enter, prevent default form submission -->
<textarea @keydown:ctrl+enter.prevent="submitDraft()"></textarea>
<!-- Ignore bubbling clicks; only fire when the element itself is clicked -->
<button click.trigger="destroy()" @click:left.stop.prevent></button>
<!-- When using dot syntax, the command still comes first -->
<div scroll.trigger="syncScroll($event)" @scroll.prevent></div><button click.trigger:ctrl="onCtrlClick()">Ctrl + Click</button><textarea keydown.trigger:ctrl+enter="send()"></textarea><button click.trigger:stop:prevent="validate()">Validate</button><button click.trigger:middle="newTab()">Open in New Tab (Middle Click)</button><textarea keydown.trigger:ctrl+75="openSearchDialog()"></textarea>import Aurelia, { AppTask, IKeyMapping } from 'aurelia';
Aurelia.register(
AppTask.creating(IKeyMapping, mapping => {
mapping.keys.upper_k = 'K'; // Map 'upper_k' to 'K'
})
);<textarea keydown.trigger:ctrl+upper_k="openSearchDialog()"></textarea>import { EventModifierRegistration, IModifiedEventHandlerCreator } from '@aurelia/runtime-html';
import { Registration } from '@aurelia/kernel';
class WheelModifier implements IModifiedEventHandlerCreator {
public readonly type = 'wheel';
public getHandler(modifier: string) {
const parts = modifier.split('.');
return (event: WheelEvent) => {
if (parts.includes('vertical') && Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return false; // Ignore horizontal scrolls
}
if (parts.includes('invert')) {
event.deltaY *= -1;
}
return true;
};
}
}
Aurelia.register(
EventModifierRegistration,
Registration.singleton(IModifiedEventHandlerCreator, WheelModifier)
);Master the art of dynamic styling in Aurelia 2. Learn everything from basic class toggling to advanced CSS custom properties, plus component styling strategies that will make your apps both beautiful
.class Syntax!important Declaration<button submit.class="isFormValid">Submit Form</button>
<div loading.class="isLoading">Content here...</div>
<nav-item active.class="isCurrentPage">Home</nav-item>export class MyComponent {
isFormValid = false;
isLoading = true;
isCurrentPage = false;
// When isFormValid becomes true, the 'submit' class gets added
// When isLoading is false, the 'loading' class gets removed
}<div class.bind="{ 'width-[360px]': condition }"></div><div alert,alert-danger,fade-in,shake.class="hasError">
Error message content
</div>export class ErrorComponent {
hasError = false;
triggerError() {
this.hasError = true; // All four classes get added at once!
}
clearError() {
this.hasError = false; // All four classes get removed together
}
}<div background-color.style="themeColor">Themed content</div>
<progress width.style="progressPercentage + '%'">Loading...</progress>
<aside opacity.style="sidebarVisible ? '1' : '0.3'">Sidebar</aside>export class ThemedComponent {
themeColor = '#3498db';
progressPercentage = 75;
sidebarVisible = true;
}<!-- These do exactly the same thing! -->
<div background-color.style="myColor"></div>
<div style.background-color="myColor"></div>
<!-- Works with any CSS property -->
<div font-size.style="textSize"></div>
<div style.font-size="textSize"></div><div --primary-color.style="brandColor">
<p style="color: var(--primary-color)">Branded text!</p>
</div>
<!-- Or with the alternative syntax -->
<div style.--primary-color="brandColor">
<p style="color: var(--primary-color)">Same result!</p>
</div>export class ThemeManager {
brandColor = '#e74c3c';
switchToDarkMode() {
this.brandColor = '#34495e';
}
}<div -webkit-user-select.style="userSelectValue">Non-selectable content</div>
<div style.-webkit-user-select="userSelectValue">Alternative syntax</div>export class ImportantComponent {
criticalColor = 'red!important';
// Aurelia automatically:
// 1. Strips the !important from the value
// 2. Sets the CSS property priority correctly
// 3. Applies the style with proper priority
}<div class.bind="dynamicClasses">Content with dynamic classes</div>
<div class="base-class ${additionalClasses}">Mixed static and dynamic</div>export class FlexibleComponent {
dynamicClasses = 'btn btn-primary active';
additionalClasses = 'fade-in hover-effect';
updateClasses() {
this.dynamicClasses = `btn btn-${this.isSuccess ? 'success' : 'danger'}`;
}
}<div style.bind="cardStyles">Beautifully styled card</div>export class StylishComponent {
cardStyles = {
backgroundColor: '#ffffff',
border: '1px solid #e1e1e1',
borderRadius: '8px',
padding: '16px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
};
switchToNightMode() {
this.cardStyles = {
...this.cardStyles,
backgroundColor: '#2d3748',
color: '#ffffff',
borderColor: '#4a5568'
};
}
}<div style="padding: 16px; background: ${bgColor}; transform: scale(${scale})">
Combined static and dynamic styles
</div>export class HybridComponent {
bgColor = 'linear-gradient(45deg, #3498db, #2ecc71)';
scale = 1.0;
animateIn() {
this.scale = 1.1;
}
}export class ComputedStyleComponent {
progress = 0.7;
theme = 'light';
get progressBarStyles() {
return {
width: `${this.progress * 100}%`,
backgroundColor: this.theme === 'dark' ? '#3498db' : '#2ecc71',
transition: 'all 0.3s ease'
};
}
}<div class="progress-container">
<div class="progress-bar" style.bind="progressBarStyles"></div>
</div>my-awesome-component.ts (component logic)
my-awesome-component.html (template)
my-awesome-component.css (styles - automatically imported!)/* my-awesome-component.css */
:host {
display: block;
padding: 16px;
}
.content {
background: linear-gradient(45deg, #3498db, #2ecc71);
border-radius: 8px;
}import { useShadowDOM } from 'aurelia';
@useShadowDOM()
export class IsolatedComponent {
// Styles are completely encapsulated
}// Open mode (default) - JavaScript can access shadowRoot
@useShadowDOM({ mode: 'open' })
export class OpenComponent { }
// Closed mode - shadowRoot is not accessible
@useShadowDOM({ mode: 'closed' })
export class ClosedComponent { }
// To use Light DOM (no Shadow DOM), simply don't use the decorator
export class LightDomComponent { }/* Style the component host element */
:host {
display: block;
border: 1px solid #e1e1e1;
}
/* Style the host when it has a specific class */
:host(.active) {
background-color: #f8f9fa;
}
/* Style the host based on ancestor context */
:host-context(.dark-theme) {
background-color: #2d3748;
color: #ffffff;
}
/* Style slotted content */
::slotted(.special-content) {
font-weight: bold;
color: #3498db;
}// main.ts
import Aurelia, { StyleConfiguration } from 'aurelia';
import { MyApp } from './my-app';
import bootstrap from 'bootstrap/dist/css/bootstrap.css';
import customTheme from './theme.css';
Aurelia
.register(StyleConfiguration.shadowDOM({
sharedStyles: [bootstrap, customTheme]
}))
.app(MyApp)
.start();import { customElement, cssModules } from 'aurelia';
// Import the CSS module (bundler provides the class mapping)
import styles from './my-component.module.css';
// styles = { title: 'title_abc123', button: 'button_def456' }
@customElement({
name: 'my-component',
template: `
<h1 class="title">My Title</h1>
<button class="button">Click Me</button>
`,
dependencies: [cssModules(styles)]
})
export class MyComponent {}export class ResponsiveComponent {
screenSize = 'desktop';
get responsiveClasses() {
return {
'mobile-layout': this.screenSize === 'mobile',
'tablet-layout': this.screenSize === 'tablet',
'desktop-layout': this.screenSize === 'desktop'
};
}
@listener('resize', window)
updateScreenSize() {
const width = window.innerWidth;
if (width < 768) {
this.screenSize = 'mobile';
} else if (width < 1024) {
this.screenSize = 'tablet';
} else {
this.screenSize = 'desktop';
}
}
}<div class.bind="responsiveClasses">
<header class="header ${screenSize === 'mobile' ? 'mobile-header' : ''}">
<!-- Responsive header -->
</header>
</div>export class ThemeManager {
currentTheme = 'light';
get themeVariables() {
const themes = {
light: {
'--primary-color': '#3498db',
'--background-color': '#ffffff',
'--text-color': '#333333'
},
dark: {
'--primary-color': '#2ecc71',
'--background-color': '#2d3748',
'--text-color': '#ffffff'
}
};
return themes[this.currentTheme];
}
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
}
}<div style.bind="themeVariables" class="theme-container">
<button
style="background: var(--primary-color); color: var(--text-color)"
click.trigger="toggleTheme()">
Toggle Theme
</button>
</div>export class LoadingComponent {
isLoading = false;
loadingProgress = 0;
async loadData() {
this.isLoading = true;
this.loadingProgress = 0;
// Simulate loading with progress
const interval = setInterval(() => {
this.loadingProgress += 10;
if (this.loadingProgress >= 100) {
clearInterval(interval);
this.isLoading = false;
}
}, 100);
}
get progressBarStyle() {
return {
width: `${this.loadingProgress}%`,
transition: 'width 0.1s ease'
};
}
}
}<div loading.class="isLoading">
<div class="progress-container" show.bind="isLoading">
<div class="progress-bar" style.bind="progressBarStyle"></div>
</div>
<div class="content" hide.bind="isLoading">
<!-- Your actual content -->
</div>
</div>export class ValidationForm {
email = '';
password = '';
get emailValidation() {
return {
isEmpty: !this.email,
isInvalid: this.email && !this.isValidEmail(this.email),
isValid: this.email && this.isValidEmail(this.email)
};
}
get passwordValidation() {
return {
isEmpty: !this.password,
isTooShort: this.password && this.password.length < 8,
isValid: this.password && this.password.length >= 8
};
}
isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}<form>
<div class="field">
<input
type="email"
value.bind="email"
empty.class="emailValidation.isEmpty"
invalid.class="emailValidation.isInvalid"
valid.class="emailValidation.isValid">
<span
class="error-message"
show.bind="emailValidation.isInvalid">
Please enter a valid email
</span>
<span
class="success-indicator"
show.bind="emailValidation.isValid">
✓
</span>
</div>
</form>export class OptimizedComponent {
private _cachedStyles: any = null;
private _lastTheme: string = '';
// Cache expensive style calculations
get expensiveStyles() {
if (this._cachedStyles && this._lastTheme === this.currentTheme) {
return this._cachedStyles;
}
this._cachedStyles = this.calculateComplexStyles();
this._lastTheme = this.currentTheme;
return this._cachedStyles;
}
private calculateComplexStyles() {
// Your expensive calculations here
return { /* styles */ };
}
}// ❌ This won't trigger updates
export class BadComponent {
styles = { color: 'red' };
changeColor() {
this.styles.color = 'blue'; // Mutation won't be detected
}
}
// ✅ This will work
export class GoodComponent {
styles = { color: 'red' };
changeColor() {
this.styles = { ...this.styles, color: 'blue' }; // New object
}
}<!-- Aurelia 1 & 2 (still works) -->
<div class.bind="myClasses"></div>
<!-- Aurelia 2 (new!) -->
<div loading,spinner,active.class="isLoading"></div>export class PreferencesForm {
emailNotifications = false;
smsNotifications = true;
pushNotifications = false;
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>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}</strong>
</div>
</form>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">
<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>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}</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}
</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>export class ShippingForm {
shippingMethods = ['Standard', 'Express', 'Overnight'];
selectedMethod = 'Standard';
}<fieldset>
<legend>Shipping Method</legend>
<label repeat.for="method of shippingMethods">
<input type="radio"
name="shipping"
model.bind="method"
checked.bind="selectedMethod" />
${method}
</label>
</fieldset>
<p>Selected: ${selectedMethod}</p>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;
};
get totalFee(): number {
return this.selectedPaymentMethod?.fee || 0;
}
get requiresUserVerification(): boolean {
return this.selectedPaymentMethod?.requiresVerification || false;
}
}<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" />
<div class="payment-info">
<div class="payment-header">
<span class="payment-name">${method.name}</span>
<span class="payment-type badge">${method.type}</span>
</div>
<div class="payment-details">
<div class="processing-time">⏱️ ${method.processingTime}</div>
<div class="fee-info">
💵 ${method.fee === 0 ? 'No fees' : '$' + method.fee.toFixed(2)}
</div>
<div if.bind="method.requiresVerification" class="verification-required">
🛡️ Verification required
</div>
</div>
</div>
</label>
</div>
</div>
<!-- Selection Summary -->
<div if.bind="selectedPaymentMethod" class="selection-summary">
<h4>Payment Summary</h4>
<p>Method: ${selectedPaymentMethod.name}</p>
<p>Processing: ${selectedPaymentMethod.processingTime}</p>
<p>Fee: ${totalFee === 0 ? 'Free' : '$' + totalFee.toFixed(2)}</p>
<div if.bind="requiresUserVerification" class="warning">
⚠️ This payment method requires account verification
</div>
</div>
</form>export class CountryForm {
countries = ['USA', 'Canada', 'Mexico', 'UK', 'France', 'Germany'];
selectedCountry = 'USA';
}<select value.bind="selectedCountry">
<option repeat.for="country of countries" value.bind="country">
${country}
</option>
</select>interface Country {
code: string;
name: string;
region: string;
}
export class AdvancedCountryForm {
countries: Country[] = [
{ code: 'US', name: 'United States', region: 'North America' },
{ code: 'CA', name: 'Canada', region: 'North America' },
{ code: 'MX', name: 'Mexico', region: 'North America' },
{ code: 'UK', name: 'United Kingdom', region: 'Europe' },
{ code: 'FR', name: 'France', region: 'Europe' },
{ code: 'DE', name: 'Germany', region: 'Europe' }
];
selectedCountry: Country | null = null;
// Custom matcher
countryMatcher = (a: Country, b: Country) => a?.code === b?.code;
}<!-- Using model.bind for objects -->
<select value.bind="selectedCountry" matcher.bind="countryMatcher">
<option model.bind="null">-- Select Country --</option>
<option repeat.for="country of countries" model.bind="country">
${country.name}
</option>
</select>
<p if.bind="selectedCountry">
Selected: ${selectedCountry.name} (${selectedCountry.region})
</p><select value.bind="selectedCountry" matcher.bind="countryMatcher">
<option model.bind="null">-- Select Country --</option>
<optgroup label="North America">
<option repeat.for="country of countries | filter:isNorthAmerica"
model.bind="country">
${country.name}
</option>
</optgroup>
<optgroup label="Europe">
<option repeat.for="country of countries | filter:isEurope"
model.bind="country">
${country.name}
</option>
</optgroup>
</select>export class MultiSelectForm {
availableSkills = ['JavaScript', 'TypeScript', 'Python', 'Java', 'C#', 'Go'];
selectedSkills: string[] = ['JavaScript', 'TypeScript'];
}<select multiple value.bind="selectedSkills">
<option repeat.for="skill of availableSkills" value.bind="skill">
${skill}
</option>
</select>
<div if.bind="selectedSkills.length">
<h4>Selected Skills (${selectedSkills.length})</h4>
<ul>
<li repeat.for="skill of selectedSkills">${skill}</li>
</ul>
</div>// Simple matcher for objects with id property
simpleMatcher = (a, b) => a?.id === b?.id;
// Type-safe matcher
typedMatcher = (a: Product, b: Product) => a?.id === b?.id;
// Complex matcher with multiple criteria
complexMatcher = (a, b) => {
if (!a || !b) return false;
return a.id === b.id && a.version === b.version;
};
// Mixed type matcher (for Sets with objects)
mixedMatcher = (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;
};export class BulkSelectionForm {
items = [/* array of items */];
selectedItems: any[] = [];
get allSelected(): boolean {
return this.selectedItems.length === this.items.length;
}
get someSelected(): boolean {
return this.selectedItems.length > 0 && !this.allSelected;
}
toggleAll() {
if (this.allSelected) {
this.selectedItems = [];
} else {
this.selectedItems = [...this.items];
}
}
}<label>
<input type="checkbox"
checked.bind="allSelected"
click.trigger="toggleAll()"
indeterminate.bind="someSelected" />
Select All
</label>
<label repeat.for="item of items">
<input type="checkbox"
model.bind="item"
checked.bind="selectedItems" />
${item.name}
</label><select value.bind="selectedOption">
<option repeat.for="option of options"
model.bind="option"
disabled.bind="option.disabled">
${option.name}
${option.disabled ? '(unavailable)' : ''}
</option>
</select>interface Product {
id: number;
name: string;
description: string;
price: number;
category: string;
image: string;
inStock: boolean;
rating: number;
}
type SortOption = 'name' | 'price-low' | 'price-high' | 'rating';
export class ProductCatalog {
// Data
products: Product[] = [
{
id: 1,
name: 'Wireless Headphones',
description: 'Premium noise-canceling headphones with 30-hour battery',
price: 299.99,
category: 'Audio',
image: '/images/headphones.jpg',
inStock: true,
rating: 4.5
},
{
id: 2,
name: 'Smart Watch',
description: 'Fitness tracking with heart rate monitor and GPS',
price: 399.99,
category: 'Wearables',
image: '/images/smartwatch.jpg',
inStock: true,
rating: 4.2
},
{
id: 3,
name: 'Laptop Stand',
description: 'Ergonomic aluminum stand for better posture',
price: 49.99,
category: 'Accessories',
image: '/images/stand.jpg',
inStock: false,
rating: 4.8
},
{
id: 4,
name: 'Mechanical Keyboard',
description: 'RGB backlit with customizable switches',
price: 159.99,
category: 'Accessories',
image: '/images/keyboard.jpg',
inStock: true,
rating: 4.6
},
{
id: 5,
name: 'USB-C Hub',
description: '7-in-1 adapter with 4K HDMI and SD card reader',
price: 79.99,
category: 'Accessories',
image: '/images/hub.jpg',
inStock: true,
rating: 4.3
},
{
id: 6,
name: 'Wireless Earbuds',
description: 'True wireless with active noise cancellation',
price: 199.99,
category: 'Audio',
image: '/images/earbuds.jpg',
inStock: true,
rating: 4.4
}
];
// Filter state
searchQuery = '';
selectedCategories: string[] = [];
sortBy: SortOption = 'name';
showOutOfStock = true;
// Computed property for unique categories
get categories(): string[] {
return [...new Set(this.products.map(p => p.category))].sort();
}
// Computed property for filtered and sorted products
get filteredProducts(): Product[] {
let filtered = this.products;
// Filter by search query
if (this.searchQuery.trim()) {
const query = this.searchQuery.toLowerCase();
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query)
);
}
// Filter by selected categories
if (this.selectedCategories.length > 0) {
filtered = filtered.filter(p =>
this.selectedCategories.includes(p.category)
);
}
// Filter out of stock if needed
if (!this.showOutOfStock) {
filtered = filtered.filter(p => p.inStock);
}
// Sort products
return this.sortProducts(filtered);
}
get hasActiveFilters(): boolean {
return this.searchQuery.trim() !== '' ||
this.selectedCategories.length > 0 ||
!this.showOutOfStock;
}
private sortProducts(products: Product[]): Product[] {
const sorted = [...products];
switch (this.sortBy) {
case 'name':
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case 'price-low':
return sorted.sort((a, b) => a.price - b.price);
case 'price-high':
return sorted.sort((a, b) => b.price - a.price);
case 'rating':
return sorted.sort((a, b) => b.rating - a.rating);
default:
return sorted;
}
}
clearFilters() {
this.searchQuery = '';
this.selectedCategories = [];
this.showOutOfStock = true;
}
setSortOrder(sortOption: SortOption) {
this.sortBy = sortOption;
}
}<div class="product-catalog">
<!-- Header -->
<header class="catalog-header">
<h1>Product Catalog</h1>
<p class="result-count">
Showing ${filteredProducts.length} of ${products.length} products
</p>
</header>
<!-- Search and Filters -->
<div class="filters-section">
<!-- Search Bar -->
<div class="search-box">
<input
type="search"
value.bind="searchQuery & debounce:300"
placeholder="Search products..."
class="search-input">
<span class="search-icon">🔍</span>
</div>
<!-- Category Filters -->
<div class="filter-group">
<h3>Categories</h3>
<label repeat.for="category of categories" class="filter-option">
<input
type="checkbox"
model.bind="category"
checked.bind="selectedCategories">
${category}
</label>
</div>
<!-- Availability Filter -->
<div class="filter-group">
<label class="filter-option">
<input type="checkbox" checked.bind="showOutOfStock">
Show out of stock items
</label>
</div>
<!-- Clear Filters -->
<button
if.bind="hasActiveFilters"
click.trigger="clearFilters()"
class="clear-filters-btn">
Clear All Filters
</button>
</div>
<!-- Sort Options -->
<div class="sort-section">
<label>Sort by:</label>
<button
click.trigger="setSortOrder('name')"
class="sort-btn ${sortBy === 'name' ? 'active' : ''}">
Name
</button>
<button
click.trigger="setSortOrder('price-low')"
class="sort-btn ${sortBy === 'price-low' ? 'active' : ''}">
Price: Low to High
</button>
<button
click.trigger="setSortOrder('price-high')"
class="sort-btn ${sortBy === 'price-high' ? 'active' : ''}">
Price: High to Low
</button>
<button
click.trigger="setSortOrder('rating')"
class="sort-btn ${sortBy === 'rating' ? 'active' : ''}">
Rating
</button>
</div>
<!-- Product Grid -->
<div class="product-grid" if.bind="filteredProducts.length > 0">
<div
repeat.for="product of filteredProducts; key: id"
class="product-card ${product.inStock ? '' : 'out-of-stock'}">
<!-- Product Image -->
<div class="product-image">
<img src.bind="product.image" alt.bind="product.name">
<span if.bind="!product.inStock" class="stock-badge">Out of Stock</span>
</div>
<!-- Product Info -->
<div class="product-info">
<h3 class="product-name">${product.name}</h3>
<p class="product-description">${product.description}</p>
<!-- Rating -->
<div class="product-rating">
<span repeat.for="star of 5" class="star ${star < product.rating ? 'filled' : ''}">
★
</span>
<span class="rating-value">${product.rating}</span>
</div>
<!-- Price and Actions -->
<div class="product-footer">
<span class="product-price">${product.price | currency:'USD'}</span>
<button
class="add-to-cart-btn"
disabled.bind="!product.inStock"
click.trigger="addToCart(product)">
${product.inStock ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div if.bind="filteredProducts.length === 0" class="empty-state">
<p class="empty-icon">📦</p>
<h2>No products found</h2>
<p>Try adjusting your search or filters</p>
<button click.trigger="clearFilters()" class="btn-primary">
Clear Filters
</button>
</div>
</div>.product-catalog {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.catalog-header {
margin-bottom: 2rem;
}
.result-count {
color: #666;
margin-top: 0.5rem;
}
.filters-section {
background: #f5f5f5;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.search-box {
position: relative;
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.search-icon {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.filter-group {
margin-bottom: 1rem;
}
.filter-group h3 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-transform: uppercase;
color: #333;
}
.filter-option {
display: block;
margin-bottom: 0.5rem;
cursor: pointer;
}
.filter-option input {
margin-right: 0.5rem;
}
.clear-filters-btn {
background: #fff;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.clear-filters-btn:hover {
background: #f0f0f0;
}
.sort-section {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.sort-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.sort-btn:hover {
border-color: #007bff;
}
.sort-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.product-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-card.out-of-stock {
opacity: 0.6;
}
.product-image {
position: relative;
height: 200px;
background: #f5f5f5;
overflow: hidden;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.stock-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: #dc3545;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.product-info {
padding: 1rem;
}
.product-name {
font-size: 1.1rem;
margin: 0 0 0.5rem 0;
color: #333;
}
.product-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.product-rating {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 1rem;
}
.star {
color: #ddd;
font-size: 1rem;
}
.star.filled {
color: #ffc107;
}
.rating-value {
margin-left: 0.25rem;
color: #666;
font-size: 0.9rem;
}
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 1.25rem;
font-weight: 600;
color: #007bff;
}
.add-to-cart-btn {
padding: 0.5rem 1rem;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.add-to-cart-btn:hover:not(:disabled) {
background: #218838;
}
.add-to-cart-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h2 {
color: #333;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #666;
margin-bottom: 1.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #0056b3;
}
@media (max-width: 768px) {
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.sort-section {
font-size: 0.9rem;
}
.sort-btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
}<input value.bind="searchQuery & debounce:300">get filteredProducts(): Product[] {
// Filters are applied in sequence
// Search → Categories → Stock availability → Sort
}<input type="checkbox" model.bind="category" checked.bind="selectedCategories"><div repeat.for="product of filteredProducts; key: id"><button class="sort-btn ${sortBy === 'name' ? 'active' : ''}">
<div class="product-card ${product.inStock ? '' : 'out-of-stock'}">minPrice = 0;
maxPrice = 500;
get filteredProducts(): Product[] {
// ... existing filters
filtered = filtered.filter(p =>
p.price >= this.minPrice && p.price <= this.maxPrice
);
// ... sort
}<div class="filter-group">
<h3>Price Range</h3>
<input type="range" min="0" max="500" value.bind="minPrice">
<input type="range" min="0" max="500" value.bind="maxPrice">
<p>${minPrice | currency} - ${maxPrice | currency}</p>
</div>cart: Product[] = [];
addToCart(product: Product) {
this.cart.push(product);
// Show notification
console.log(`Added ${product.name} to cart`);
}import { resolve } from 'aurelia';
import { IRouter } from '@aurelia/router';
export class ProductCatalog {
private readonly router = resolve(IRouter);
searchQueryChanged() {
this.router.load({
query: { search: this.searchQuery }
});
}
}pattern and symbolsexport interface AttributePatternDefinition {
pattern: string; // Pattern with PART placeholders
symbols: string; // Characters treated as separators/delimiters
}{ pattern: 'foo@PART', symbols: '@' }import { attributePattern, AttrSyntax } from '@aurelia/template-compiler';
@attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
export class AngularTwoWayBindingAttributePattern {
public ['[(PART)]'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
}
}export class AttrSyntax {
public constructor(
public rawName: string, // Original attribute name
public rawValue: string, // Original attribute value
public target: string, // Target property/element
public command: string | null, // Binding command
public parts: readonly string[] | null = null // Additional parts for complex patterns
) {}
}import { Aurelia } from 'aurelia';
import { AngularTwoWayBindingAttributePattern } from './patterns/angular-patterns';
Aurelia
.register(AngularTwoWayBindingAttributePattern)
.app(MyApp)
.start();import { customElement } from '@aurelia/runtime-html';
import { AngularTwoWayBindingAttributePattern } from './patterns/angular-patterns';
@customElement({
name: 'my-component',
template: '<input [(value)]="message">',
dependencies: [AngularTwoWayBindingAttributePattern]
})
export class MyComponent {
public message = 'Hello World';
}import { AttributePattern } from '@aurelia/template-compiler';
@customElement({
name: 'my-component',
template: '<input !value="message">',
dependencies: [
// Define pattern inline and register directly
(() => {
@attributePattern({ pattern: '!PART', symbols: '!' })
class InlineExclamationPattern {
'!PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
return InlineExclamationPattern;
})()
]
})@attributePattern(
{ pattern: 'PART#PART', symbols: '#' }, // view-model#uploadVM
{ pattern: '#PART', symbols: '#' } // #uploadInput
)
export class AngularSharpRefAttributePattern {
public ['PART#PART'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, parts[1], parts[0], 'ref');
}
public ['#PART'](rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, parts[0], 'element', 'ref');
}
}// Given patterns: 'PART.PART', 'value.PART', 'PART.bind'
// For attribute 'value.bind':
// - 'value.PART' matches with 1 static + 1 dynamic = higher priority
// - 'PART.bind' matches with 1 dynamic + 1 static = same priority
// - 'PART.PART' matches with 2 dynamic = lower priority
// Result: First pattern with highest static count wins@attributePattern(
{ pattern: 'PART.trigger:PART', symbols: '.:' },
{ pattern: 'PART.capture:PART', symbols: '.:' }
)
export class EventModifierPattern {
public 'PART.trigger:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', parts);
}
public 'PART.capture:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'capture', parts);
}
}@attributePattern(
{ pattern: 'promise.resolve', symbols: '' },
{ pattern: 'promise.catch', symbols: '' },
{ pattern: 'ref', symbols: '' }
)
export class StaticPatterns {
public 'promise.resolve'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, rawValue, 'promise-resolve');
}
public 'promise.catch'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, rawValue, 'promise-catch');
}
public 'ref'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'element', 'ref');
}
}@attributePattern({ pattern: 'PART.PART.PART', symbols: '.' })
export class ThreePartPattern {
public 'PART.PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
// For something like 'user.address.street.bind'
// parts = ['user', 'address', 'street', 'bind']
const target = `${parts[0]}.${parts[1]}.${parts[2]}`;
return new AttrSyntax(rawName, rawValue, target, parts[3]);
}
}// Built-in: handles 'value.bind', 'checked.two-way', etc.
@attributePattern(
{ pattern: 'PART.PART', symbols: '.' },
{ pattern: 'PART.PART.PART', symbols: '.' }
)
export class DotSeparatedAttributePattern {
public 'PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], parts[1]);
}
public 'PART.PART.PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, `${parts[0]}.${parts[1]}`, parts[2]);
}
}// Built-in: handles ':value', '@click', etc.
@attributePattern({ pattern: ':PART', symbols: ':' })
export class ColonPrefixedBindAttributePattern {
public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
@attributePattern(
{ pattern: '@PART', symbols: '@' },
{ pattern: '@PART:PART', symbols: '@:' }
)
export class AtPrefixedTriggerAttributePattern {
public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
public '@PART:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger', [parts[0], 'trigger', ...parts.slice(1)]);
}
}// Angular ref syntax: #myInput
@attributePattern({ pattern: '#PART', symbols: '#' })
export class AngularRefPattern {
public '#PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, parts[0], 'element', 'ref');
}
}
// Angular property binding: [value]
@attributePattern({ pattern: '[PART]', symbols: '[]' })
export class AngularPropertyBinding {
public '[PART]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
// Angular event binding: (click)
@attributePattern({ pattern: '(PART)', symbols: '()' })
export class AngularEventBinding {
public '(PART)'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
}
// Angular two-way binding: [(ngModel)]
@attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
export class AngularTwoWayBinding {
public '[(PART)]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
}
}// Vue property binding: :value
@attributePattern({ pattern: ':PART', symbols: ':' })
export class VuePropertyBinding {
public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
// Vue event binding: @click
@attributePattern({ pattern: '@PART', symbols: '@' })
export class VueEventBinding {
public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
}
// Vue v-model directive
@attributePattern({ pattern: 'v-model', symbols: '' })
export class VueModelDirective {
public 'v-model'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'value', 'two-way');
}
}// Enable debug logging to see pattern matching
import { LoggerConfiguration, LogLevel } from '@aurelia/kernel';
Aurelia
.register(LoggerConfiguration.create({ level: LogLevel.debug }))
.register(MyPatternClass)
.app(MyApp)
.start();// Testing patterns is typically done through the DI container
import { DI } from '@aurelia/kernel';
import { ISyntaxInterpreter, IAttributePattern } from '@aurelia/template-compiler';
// Create a container and register your pattern
const container = DI.createContainer();
container.register(MyPatternClass);
const interpreter = container.get(ISyntaxInterpreter);
const attrPattern = container.get(IAttributePattern);
// Test pattern interpretation
const result = interpreter.interpret('[(value)]');
if (result.pattern) {
console.log('Pattern matched:', result.pattern);
console.log('Parts:', result.parts);
}// Complete React-like pattern system
@attributePattern(
{ pattern: 'className', symbols: '' },
{ pattern: 'onClick', symbols: '' },
{ pattern: 'onChange', symbols: '' }
)
export class ReactLikePatterns {
public 'className'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'class', 'bind');
}
public 'onClick'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'click', 'trigger');
}
public 'onChange'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'change', 'trigger');
}
}// Advanced pattern for component communication
@attributePattern(
{ pattern: 'emit:PART', symbols: ':' },
{ pattern: 'listen:PART', symbols: ':' },
{ pattern: 'sync:PART', symbols: ':' }
)
export class ComponentCommunicationPatterns {
public 'emit:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'emit-event');
}
public 'listen:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'listen-event');
}
public 'sync:PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'sync-prop');
}
}// attr-patterns.ts
import { attributePattern, AttrSyntax } from '@aurelia/template-compiler';
// Angular-style patterns
@attributePattern({ pattern: '#PART', symbols: '#' })
export class AngularSharpRefAttributePattern {
public '#PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, parts[0], 'element', 'ref');
}
}
@attributePattern({ pattern: '[PART]', symbols: '[]' })
export class AngularOneWayBindingAttributePattern {
public '[PART]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
@attributePattern({ pattern: '(PART)', symbols: '()' })
export class AngularEventBindingAttributePattern {
public '(PART)'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
}
@attributePattern({ pattern: '[(PART)]', symbols: '[()]' })
export class AngularTwoWayBindingAttributePattern {
public '[(PART)]'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
}
}
// Vue-style patterns
@attributePattern({ pattern: ':PART', symbols: ':' })
export class VueOneWayBindingAttributePattern {
public ':PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'bind');
}
}
@attributePattern({ pattern: '@PART', symbols: '@' })
export class VueEventBindingAttributePattern {
public '@PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'trigger');
}
}
@attributePattern({ pattern: 'v-model', symbols: '' })
export class VueTwoWayBindingAttributePattern {
public 'v-model'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, 'value', 'two-way');
}
}
// Custom patterns
@attributePattern({ pattern: '::PART', symbols: '::' })
export class DoubleColonTwoWayBindingAttributePattern {
public '::PART'(rawName: string, rawValue: string, parts: readonly string[]): AttrSyntax {
return new AttrSyntax(rawName, rawValue, parts[0], 'two-way');
}
}// main.ts
import { Aurelia } from 'aurelia';
import {
AngularEventBindingAttributePattern,
AngularOneWayBindingAttributePattern,
AngularSharpRefAttributePattern,
AngularTwoWayBindingAttributePattern,
DoubleColonTwoWayBindingAttributePattern,
VueEventBindingAttributePattern,
VueOneWayBindingAttributePattern,
VueTwoWayBindingAttributePattern
} from './attr-patterns';
Aurelia
.register(
AngularSharpRefAttributePattern,
AngularOneWayBindingAttributePattern,
AngularEventBindingAttributePattern,
AngularTwoWayBindingAttributePattern,
VueOneWayBindingAttributePattern,
VueEventBindingAttributePattern,
VueTwoWayBindingAttributePattern,
DoubleColonTwoWayBindingAttributePattern
)
.app(MyApp)
.start();Build a flexible modal dialog component with backdrop, animations, and focus management
<div attribute-name.bind="value"></div>title Attribute.bindinnerHTML and textContentas-element.attr Binding Commandattr Binding Behaviorrole="dialog", aria-modal="true" for screen readersnpx makes aurelia
# Name: todo-app
# Select TypeScript
cd todo-app
npm run devexport interface Todo {
id: string;
title: string;
description: string;
category: Category;
completed: boolean;
createdAt: Date;
}
export type Category = 'work' | 'personal' | 'shopping';
export const CATEGORIES: Category[] = ['work', 'personal', 'shopping'];
export const CATEGORY_LABELS: Record<Category, string> = {
work: 'Work',
personal: 'Personal',
shopping: 'Shopping'
};
export const CATEGORY_COLORS: Record<Category, string> = {
work: '#3b82f6',
personal: '#10b981',
shopping: '#f59e0b'
};import { DI } from 'aurelia';
export const IStorageService = DI.createInterface<IStorageService>(
'IStorageService',
x => x.singleton(StorageService)
);
export interface IStorageService extends StorageService {}
export class StorageService {
private readonly STORAGE_KEY = 'aurelia-todos';
saveTodos(todos: any[]): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save todos:', error);
}
}
loadTodos(): any[] {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Failed to load todos:', error);
return [];
}
}
clearTodos(): void {
localStorage.removeItem(this.STORAGE_KEY);
}
}import { resolve } from 'aurelia';
import { IStorageService } from './storage-service';
import { Todo, Category, CATEGORIES } from './models';
export class MyApp {
private readonly storage = resolve(IStorageService);
todos: Todo[] = [];
filterCategory: Category | 'all' = 'all';
filterCompleted: 'all' | 'active' | 'completed' = 'all';
constructor() {
this.loadTodos();
}
// Computed property for filtered todos
get filteredTodos(): Todo[] {
let filtered = this.todos;
// Filter by category
if (this.filterCategory !== 'all') {
filtered = filtered.filter(todo => todo.category === this.filterCategory);
}
// Filter by completion status
if (this.filterCompleted === 'active') {
filtered = filtered.filter(todo => !todo.completed);
} else if (this.filterCompleted === 'completed') {
filtered = filtered.filter(todo => todo.completed);
}
return filtered;
}
// Statistics computed properties
get totalTodos(): number {
return this.todos.length;
}
get activeTodos(): number {
return this.todos.filter(todo => !todo.completed).length;
}
get completedTodos(): number {
return this.todos.filter(todo => todo.completed).length;
}
get categories(): (Category | 'all')[] {
return ['all', ...CATEGORIES];
}
// Todo operations
addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): void {
const newTodo: Todo = {
...todo,
id: crypto.randomUUID(),
createdAt: new Date()
};
this.todos.push(newTodo);
this.saveTodos();
}
toggleTodo(todo: Todo): void {
todo.completed = !todo.completed;
this.saveTodos();
}
deleteTodo(todo: Todo): void {
const index = this.todos.indexOf(todo);
if (index > -1) {
this.todos.splice(index, 1);
this.saveTodos();
}
}
clearCompleted(): void {
this.todos = this.todos.filter(todo => !todo.completed);
this.saveTodos();
}
// Persistence
private saveTodos(): void {
this.storage.saveTodos(this.todos);
}
private loadTodos(): void {
const loaded = this.storage.loadTodos();
this.todos = loaded.map(todo => ({
...todo,
createdAt: new Date(todo.createdAt)
}));
}
}import { bindable } from 'aurelia';
import { Category, CATEGORIES, CATEGORY_LABELS } from './models';
export class TodoForm {
@bindable onSubmit?: (data: any) => void;
formData = {
title: '',
description: '',
category: 'work' as Category
};
categories = CATEGORIES;
categoryLabels = CATEGORY_LABELS;
get isValid(): boolean {
return this.formData.title.trim().length > 0;
}
handleSubmit(): void {
if (!this.isValid) return;
this.onSubmit?.({
title: this.formData.title.trim(),
description: this.formData.description.trim(),
category: this.formData.category,
completed: false
});
this.resetForm();
}
resetForm(): void {
this.formData = {
title: '',
description: '',
category: 'work'
};
}
}<div class="todo-form">
<h2>Add New Todo</h2>
<form submit.trigger="handleSubmit()">
<div class="form-group">
<label for="title">Title *</label>
<input
id="title"
type="text"
value.bind="formData.title"
placeholder="Enter todo title"
required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
value.bind="formData.description"
placeholder="Optional description"
rows="3"></textarea>
</div>
<div class="form-group">
<label for="category">Category</label>
<select id="category" value.bind="formData.category">
<option repeat.for="cat of categories" value.bind="cat">
${categoryLabels[cat]}
</option>
</select>
</div>
<button type="submit" disabled.bind="!isValid">
Add Todo
</button>
</form>
</div>import { bindable } from 'aurelia';
import { Todo, CATEGORY_LABELS, CATEGORY_COLORS } from './models';
export class TodoItem {
@bindable todo!: Todo;
@bindable onToggle?: (todo: Todo) => void;
@bindable onDelete?: (todo: Todo) => void;
categoryLabels = CATEGORY_LABELS;
categoryColors = CATEGORY_COLORS;
get categoryColor(): string {
return this.categoryColors[this.todo.category];
}
handleToggle(): void {
this.onToggle?.(this.todo);
}
handleDelete(): void {
if (confirm(`Delete "${this.todo.title}"?`)) {
this.onDelete?.(this.todo);
}
}
get formattedDate(): string {
return this.todo.createdAt.toLocaleDateString();
}
}<div class="todo-item ${todo.completed ? 'completed' : ''}">
<div class="todo-content">
<label class="todo-checkbox">
<input
type="checkbox"
checked.bind="todo.completed"
change.trigger="handleToggle()" />
<span class="checkmark"></span>
</label>
<div class="todo-details">
<h3 class="todo-title">${todo.title}</h3>
<p if.bind="todo.description" class="todo-description">
${todo.description}
</p>
<div class="todo-meta">
<span class="todo-category" style="background-color: ${categoryColor}">
${categoryLabels[todo.category]}
</span>
<span class="todo-date">${formattedDate}</span>
</div>
</div>
</div>
<button
class="delete-btn"
click.trigger="handleDelete()"
title="Delete todo">
×
</button>
</div><import from="./todo-form"></import>
<import from="./todo-item"></import>
<div class="app">
<header class="app-header">
<h1>📝 Aurelia Todo App</h1>
<div class="stats">
<span class="stat">Total: ${totalTodos}</span>
<span class="stat">Active: ${activeTodos}</span>
<span class="stat">Completed: ${completedTodos}</span>
</div>
</header>
<main class="app-main">
<div class="sidebar">
<todo-form on-submit.bind="(data) => addTodo(data)"></todo-form>
</div>
<div class="content">
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label>Category:</label>
<select value.bind="filterCategory">
<option value="all">All Categories</option>
<option repeat.for="cat of categories" value.bind="cat">
${cat === 'all' ? 'All' : cat}
</option>
</select>
</div>
<div class="filter-group">
<label>Status:</label>
<select value.bind="filterCompleted">
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
</div>
<button
if.bind="completedTodos > 0"
click.trigger="clearCompleted()"
class="clear-btn">
Clear Completed
</button>
</div>
<!-- Todo List -->
<div class="todo-list">
<div if.bind="filteredTodos.length === 0" class="empty-state">
<p>No todos found!</p>
<small if.bind="filterCategory !== 'all' || filterCompleted !== 'all'">
Try changing your filters
</small>
</div>
<todo-item
repeat.for="todo of filteredTodos; key: id"
todo.bind="todo"
on-toggle.bind="(todo) => toggleTodo(todo)"
on-delete.bind="(todo) => deleteTodo(todo)">
</todo-item>
</div>
</div>
</main>
</div>* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
background: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin-bottom: 1rem;
}
.stats {
display: flex;
gap: 2rem;
}
.stat {
font-size: 0.9rem;
color: #666;
}
.app-main {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
/* Todo Form */
.todo-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.todo-form h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filters */
.filters {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: flex-end;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-group {
flex: 1;
}
.filter-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.filter-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.clear-btn {
background: #ef4444;
}
.clear-btn:hover {
background: #dc2626;
}
/* Todo List */
.todo-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
background: white;
padding: 3rem;
border-radius: 8px;
text-align: center;
color: #999;
}
/* Todo Item */
.todo-item {
background: white;
padding: 1rem;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.todo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-content {
display: flex;
gap: 1rem;
flex: 1;
}
.todo-checkbox {
cursor: pointer;
position: relative;
}
.todo-checkbox input {
cursor: pointer;
}
.todo-details {
flex: 1;
}
.todo-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
}
.todo-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.todo-meta {
display: flex;
gap: 1rem;
align-items: center;
}
.todo-category {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: white;
font-weight: 500;
}
.todo-date {
font-size: 0.8rem;
color: #999;
}
.delete-btn {
background: transparent;
color: #ef4444;
border: 1px solid #ef4444;
width: 32px;
height: 32px;
padding: 0;
font-size: 1.5rem;
line-height: 1;
}
.delete-btn:hover {
background: #ef4444;
color: white;
}
@media (max-width: 768px) {
.app-main {
grid-template-columns: 1fr;
}
.filters {
flex-direction: column;
align-items: stretch;
}
}import { bindable, IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { queueTask } from '@aurelia/runtime';
import { IPlatform } from '@aurelia/runtime-html';
export class ModalDialog {
@bindable open = false;
@bindable closeOnBackdropClick = true;
@bindable closeOnEscape = true;
@bindable size: 'small' | 'medium' | 'large' | 'full' = 'medium';
private platform = resolve(IPlatform);
private element?: HTMLElement;
private modalElement?: HTMLElement;
private previousActiveElement?: HTMLElement;
private focusableElements: HTMLElement[] = [];
openChanged(newValue: boolean) {
if (newValue) {
this.onOpen();
} else {
this.onClose();
}
}
attaching(initiator: HTMLElement) {
this.element = initiator;
this.modalElement = this.element.querySelector('[data-modal]') as HTMLElement;
}
detaching() {
// Clean up if modal is still open
if (this.open) {
this.cleanupModal();
}
}
closeModal() {
this.open = false;
}
handleBackdropClick(event: MouseEvent) {
// Only close if clicking the backdrop itself, not content inside
if (this.closeOnBackdropClick && event.target === event.currentTarget) {
this.closeModal();
}
}
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.closeOnEscape) {
event.preventDefault();
this.closeModal();
return;
}
// Tab key focus trap
if (event.key === 'Tab') {
this.handleTabKey(event);
}
}
private onOpen() {
// Store currently focused element to return focus later
this.previousActiveElement = document.activeElement as HTMLElement;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Wait for DOM to render, then focus first element
queueTask(() => {
this.updateFocusableElements();
this.focusFirstElement();
});
}
private onClose() {
this.cleanupModal();
}
private cleanupModal() {
// Restore body scroll
document.body.style.overflow = '';
// Return focus to element that opened the modal
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = undefined;
}
}
private updateFocusableElements() {
if (!this.modalElement) return;
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
this.focusableElements = Array.from(
this.modalElement.querySelectorAll(focusableSelectors)
) as HTMLElement[];
}
private focusFirstElement() {
const firstFocusable = this.focusableElements[0];
if (firstFocusable) {
firstFocusable.focus();
}
}
private handleTabKey(event: KeyboardEvent) {
if (this.focusableElements.length === 0) return;
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
const activeElement = document.activeElement as HTMLElement;
if (event.shiftKey) {
// Shift + Tab: Move backwards
if (activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: Move forwards
if (activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
}<div
if.bind="open"
class="modal modal--\${size}"
role="dialog"
aria-modal="true"
keydown.trigger="handleKeyDown($event)"
data-modal>
<!-- Backdrop -->
<div
class="modal__backdrop"
click.trigger="handleBackdropClick($event)">
<!-- Content container -->
<div class="modal__content" role="document">
<!-- Header slot -->
<div class="modal__header" if.bind="$slots.header">
<au-slot name="header"></au-slot>
<button
type="button"
class="modal__close"
click.trigger="closeModal()"
aria-label="Close modal">
×
</button>
</div>
<!-- Body slot -->
<div class="modal__body">
<au-slot>
<p>Modal content goes here</p>
</au-slot>
</div>
<!-- Footer slot -->
<div class="modal__footer" if.bind="$slots.footer">
<au-slot name="footer"></au-slot>
</div>
</div>
</div>
</div>.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: modal-fade-in 0.2s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
overflow-y: auto;
}
.modal__content {
position: relative;
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 90vh;
display: flex;
flex-direction: column;
animation: modal-slide-up 0.2s ease-out;
margin: auto;
}
@keyframes modal-slide-up {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Size variants */
.modal--small .modal__content {
width: 100%;
max-width: 400px;
}
.modal--medium .modal__content {
width: 100%;
max-width: 600px;
}
.modal--large .modal__content {
width: 100%;
max-width: 900px;
}
.modal--full .modal__content {
width: 100%;
max-width: 95vw;
max-height: 95vh;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal__header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #111827;
}
.modal__close {
background: none;
border: none;
font-size: 28px;
line-height: 1;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s;
}
.modal__close:hover {
background: #f3f4f6;
color: #111827;
}
.modal__close:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.modal__body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.modal__footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal__footer button {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.modal__footer button.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.modal__footer button.btn-primary:hover {
background: #2563eb;
}
.modal__footer button.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.modal__footer button.btn-secondary:hover {
background: #f9fafb;
}// your-component.ts
export class YourComponent {
showModal = false;
openModal() {
this.showModal = true;
}
closeModal() {
this.showModal = false;
}
}<!-- your-component.html -->
<button click.trigger="openModal()">Open Modal</button>
<modal-dialog open.bind="showModal">
<h2 au-slot="header">Welcome!</h2>
<p>This is the modal content. You can put anything here.</p>
<div au-slot="footer">
<button class="btn-secondary" click.trigger="closeModal()">Cancel</button>
<button class="btn-primary" click.trigger="closeModal()">OK</button>
</div>
</modal-dialog>export class ConfirmDialog {
showConfirm = false;
confirmMessage = '';
confirm(message: string): Promise<boolean> {
this.confirmMessage = message;
this.showConfirm = true;
return new Promise(resolve => {
this.resolveConfirm = resolve;
});
}
handleConfirm(result: boolean) {
this.showConfirm = false;
if (this.resolveConfirm) {
this.resolveConfirm(result);
}
}
async deleteItem() {
const confirmed = await this.confirm('Are you sure you want to delete this item?');
if (confirmed) {
// Delete the item
}
}
private resolveConfirm?: (value: boolean) => void;
}<modal-dialog open.bind="showConfirm" size="small">
<h2 au-slot="header">Confirm Action</h2>
<p>\${confirmMessage}</p>
<div au-slot="footer">
<button class="btn-secondary" click.trigger="handleConfirm(false)">
Cancel
</button>
<button class="btn-primary" click.trigger="handleConfirm(true)">
Confirm
</button>
</div>
</modal-dialog>export class FormModal {
showForm = false;
formData = {
name: '',
email: '',
message: ''
};
openForm() {
this.showForm = true;
this.resetForm();
}
closeForm() {
this.showForm = false;
}
async submitForm() {
// Validate and submit
console.log('Submitting:', this.formData);
this.closeForm();
}
resetForm() {
this.formData = { name: '', email: '', message: '' };
}
}<modal-dialog open.bind="showForm" size="medium" close-on-backdrop-click.bind="false">
<h2 au-slot="header">Contact Us</h2>
<form>
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" value.bind="formData.name">
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" value.bind="formData.email">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" rows="4" value.bind="formData.message"></textarea>
</div>
</form>
<div au-slot="footer">
<button class="btn-secondary" click.trigger="closeForm()">Cancel</button>
<button class="btn-primary" click.trigger="submitForm()">Send Message</button>
</div>
</modal-dialog><modal-dialog open.bind="showDetails" size="full">
<h2 au-slot="header">Full Details</h2>
<div class="content-grid">
<!-- Large amount of content -->
</div>
<div au-slot="footer">
<button class="btn-primary" click.trigger="showDetails = false">Close</button>
</div>
</modal-dialog>import { createFixture } from '@aurelia/testing';
import { ModalDialog } from './modal-dialog';
describe('ModalDialog', () => {
it('opens and closes', async () => {
const { component, queryBy, stop } = await createFixture
.html`<modal-dialog open.bind="isOpen"></modal-dialog>`
.component(class { isOpen = false; })
.deps(ModalDialog)
.build()
.started;
expect(queryBy('[data-modal]')).toBeNull();
component.isOpen = true;
await new Promise(resolve => setTimeout(resolve, 10));
expect(queryBy('[data-modal]')).toBeTruthy();
component.isOpen = false;
await new Promise(resolve => setTimeout(resolve, 10));
expect(queryBy('[data-modal]')).toBeNull();
await stop(true);
});
it('closes on Escape key', async () => {
const { component, trigger, getBy, stop } = await createFixture
.html`<modal-dialog open.bind="isOpen"></modal-dialog>`
.component(class { isOpen = true; })
.deps(ModalDialog)
.build()
.started;
await new Promise(resolve => setTimeout(resolve, 10));
trigger.keydown(getBy('[data-modal]'), { key: 'Escape' });
expect(component.isOpen).toBe(false);
await stop(true);
});
it('closes on backdrop click when enabled', async () => {
const { component, trigger, getBy, stop } = await createFixture
.html`<modal-dialog open.bind="isOpen" close-on-backdrop-click.bind="true"></modal-dialog>`
.component(class { isOpen = true; })
.deps(ModalDialog)
.build()
.started;
await new Promise(resolve => setTimeout(resolve, 10));
trigger.click(getBy('.modal__backdrop'));
expect(component.isOpen).toBe(false);
await stop(true);
});
it('does not close on content click', async () => {
const { component, trigger, getBy, stop } = await createFixture
.html`<modal-dialog open.bind="isOpen"></modal-dialog>`
.component(class { isOpen = true; })
.deps(ModalDialog)
.build()
.started;
await new Promise(resolve => setTimeout(resolve, 10));
trigger.click(getBy('.modal__content'));
expect(component.isOpen).toBe(true);
await stop(true);
});
it('prevents body scroll when open', async () => {
const { component, stop } = await createFixture
.html`<modal-dialog open.bind="isOpen"></modal-dialog>`
.component(class { isOpen = false; })
.deps(ModalDialog)
.build()
.started;
expect(document.body.style.overflow).toBe('');
component.isOpen = true;
await new Promise(resolve => setTimeout(resolve, 10));
expect(document.body.style.overflow).toBe('hidden');
component.isOpen = false;
await new Promise(resolve => setTimeout(resolve, 10));
expect(document.body.style.overflow).toBe('');
await stop(true);
});
});import { animator } from '@aurelia/runtime-html';
export class AnimatedModal {
private animator = resolve(animator);
async openModal() {
this.open = true;
await tasksSettled();
await this.animator.enter(this.modalElement!);
}
async closeModal() {
await this.animator.leave(this.modalElement!);
this.open = false;
}
}export class UnsavedChangesModal {
@bindable hasUnsavedChanges = false;
async closeModal() {
if (this.hasUnsavedChanges) {
const confirmed = confirm('You have unsaved changes. Close anyway?');
if (!confirmed) return;
}
this.open = false;
}
}// modal-service.ts
import { IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
export interface ModalConfig {
title: string;
message: string;
buttons?: Array<{ label: string; action: () => void; primary?: boolean }>;
}
export class ModalService {
private ea = resolve(IEventAggregator);
alert(title: string, message: string) {
return this.open({
title,
message,
buttons: [{ label: 'OK', action: () => {}, primary: true }]
});
}
confirm(title: string, message: string): Promise<boolean> {
return new Promise(resolve => {
this.open({
title,
message,
buttons: [
{ label: 'Cancel', action: () => resolve(false) },
{ label: 'Confirm', action: () => resolve(true), primary: true }
]
});
});
}
private open(config: ModalConfig) {
this.ea.publish('modal:open', config);
}
}.one-timetabIndex<!-- my-app.html -->
<div title.bind="tooltipText">Hover over me!</div>// my-app.ts
export class MyApp {
tooltipText = 'This is a tooltip';
}<!-- my-app.html -->
<div>
<h1 id="${headingId}">Dynamic Heading</h1>
</div>// my-app.ts
export class MyApp {
headingId = 'main-heading';
}<!-- my-app.html -->
<!-- Two-way binding: changes in input update 'firstName' and vice versa -->
<input type="text" value.two-way="firstName" placeholder="First Name">
<!-- To-view binding: changes in 'lastName' update the input, but not vice versa -->
<input type="text" value.to-view="lastName" placeholder="Last Name">
<!-- One-time binding: input value is set once from 'middleName' -->
<input type="text" value.one-time="middleName" placeholder="Middle Name">
<!-- Binding a link's href attribute using to-view -->
<a href.to-view="profile.blogUrl">Blog</a>
<!-- Binding a link's href attribute using one-time -->
<a href.one-time="profile.twitterUrl">Twitter</a>
<!-- Binding a link's href attribute using bind (auto mode) -->
<a href.bind="profile.linkedInUrl">LinkedIn</a>// my-app.ts
export class MyApp {
firstName = 'John';
lastName = 'Doe';
middleName = 'A.';
profile = {
blogUrl: 'https://johnsblog.com',
twitterUrl: 'https://twitter.com/johndoe',
linkedInUrl: 'https://linkedin.com/in/johndoe'
};
}<!-- These two lines are equivalent -->
<input value.bind="firstName">
<input :value="firstName">
<!-- Works with any attribute -->
<img :src="profile.avatarUrl" :alt="profile.fullName"><!-- my-app.html -->
<img src.bind="imageSrc" alt.bind="imageAlt" />// my-app.ts
export class MyApp {
imageSrc = 'https://example.com/image.jpg';
imageAlt = 'Example Image';
}<!-- my-app.html -->
<button disabled.bind="isButtonDisabled">Submit</button>
<input type="text" disabled.bind="isInputDisabled" placeholder="Enter text" />// my-app.ts
export class MyApp {
isButtonDisabled = true;
isInputDisabled = false;
toggleButton() {
this.isButtonDisabled = !this.isButtonDisabled;
}
toggleInput() {
this.isInputDisabled = !this.isInputDisabled;
}
}<!-- my-app.html -->
<div innerhtml.bind="htmlContent"></div>
<div textcontent.bind="plainText"></div>// my-app.ts
export class MyApp {
htmlContent = '<strong>This is bold text.</strong>';
plainText = '<strong>This is not bold text.</strong>';
}<!-- Render a section, but run it through the <page-card> custom element -->
<section as-element="page-card" header.bind="title">
<p>Projected slot content still works as usual.</p>
</section><!-- my-app.html -->
<input value.bind="userName" />// my-app.ts
export class MyApp {
userName = 'JaneDoe';
}<!-- my-app.html -->
<input my-custom-attr.attr="customValue" />// my-app.ts
export class MyApp {
customValue = 'Custom Attribute Value';
}<!-- my-app.html -->
<input pattern.bind="patternProp & attr" />
<div data-tooltip.bind="tooltipText & attr"></div>
<svg>
<circle cx.bind="centerX & attr" cy.bind="centerY & attr" r="50" />
</svg>// my-app.ts
export class MyApp {
patternProp = '[A-Za-z]{3,}';
tooltipText = 'This is a custom tooltip';
centerX = 100;
centerY = 100;
}// main.ts
import { Aurelia, AppTask, IAttrMapper } from '@aurelia/runtime-html';
Aurelia
.register(
AppTask.creating(IAttrMapper, (attrMapper) => {
attrMapper.useMapping({
'CUSTOM-INPUT': {
'max-length': 'maxLength',
'min-length': 'minLength'
}
});
attrMapper.useGlobalMapping({
'custom-attr': 'customAttribute'
});
attrMapper.useTwoWay(
(element, attr) => element.tagName === 'CUSTOM-INPUT' && attr === 'value'
);
})
)
.app(MyApp)
.start();<!-- Single class binding -->
<div class.bind="isActive && 'active'"></div>
<!-- Multiple class binding -->
<div class.bind="getClasses()"></div><!-- Style property binding -->
<div style.background-color.bind="bgColor"></div>
<div style.font-size.bind="fontSize + 'px'"></div>
<!-- Full style object binding -->
<div style.bind="styleObject"></div>export class MyApp {
bgColor = 'red';
fontSize = 16;
styleObject = {
color: 'blue',
'font-weight': 'bold',
margin: '10px'
};
getClasses() {
return this.isActive ? 'active highlight' : 'inactive';
}
}<!-- my-app.html -->
<div class.bind="isActive ? 'active' : 'inactive'">Status</div>// my-app.ts
export class MyApp {
isActive = true;
toggleStatus() {
this.isActive = !this.isActive;
}
}<!-- my-app.html -->
<div style.backgroundColor.bind="bgColor">Colored Box</div>// my-app.ts
export class MyApp {
bgColor = 'lightblue';
changeColor(newColor: string) {
this.bgColor = newColor;
}
}<!-- my-app.html -->
<input type="email" required.bind="isEmailRequired" placeholder="Enter your email" />// my-app.ts
export class MyApp {
isEmailRequired = true;
toggleEmailRequirement() {
this.isEmailRequired = !this.isEmailRequired;
}
}<!-- my-app.html -->
<span class="price">${totalPrice | currency}</span>// my-app.ts
export class MyApp {
totalPrice = 199.99;
}// currency-value-converter.ts
export class CurrencyValueConverter {
toView(value: number) {
return `$${value.toFixed(2)}`;
}
}<!-- ❌ Incorrect: Using attr with event binding -->
<button click.trigger="save() & attr">Save</button>
<!-- ✅ Correct: Remove attr from event binding -->
<button click.trigger="save()">Save</button>
<!-- ✅ Correct: Use attr with property binding -->
<input value.bind="query & attr">export class MyApp {
tooltipText: string | null = null; // Will remove title attribute
isDisabled: boolean | undefined = undefined; // Will remove disabled attribute
}<!-- These attributes will be removed when values are null/undefined -->
<div title.bind="tooltipText">Content</div>
<button disabled.bind="isDisabled">Click me</button><!-- This will create a DOM attribute, NOT invoke a custom attribute -->
<div my-custom.attr="value"></div>
<!-- This will invoke the custom attribute -->
<div my-custom.bind="value"></div><!-- These all result in disabled="disabled" or no attribute -->
<button disabled.bind="true">Always Disabled</button>
<button disabled.bind="false">Never Disabled</button>
<button disabled.bind="isDisabled">Conditionally Disabled</button><svg>
<!-- For SVG-specific attributes, use attr binding behavior -->
<circle cx.bind="x & attr" cy.bind="y & attr" r.bind="radius & attr" />
<text text-anchor.bind="anchor & attr">Label</text>
</svg><!-- ❌ Complex expression in template -->
<div class.bind="items.filter(i => i.active).length > 0 ? 'has-active' : 'no-active'"></div>
<!-- ✅ Move logic to view model -->
<div class.bind="hasActiveItems ? 'has-active' : 'no-active'"></div>export class MyApp {
get hasActiveItems() {
return this.items.some(i => i.active);
}
}import { computed } from '@aurelia/runtime';
export class MyApp {
items = [];
@computed({ deps: ['items'] })
get itemCount() {
return this.items.length;
}
}@computed('items')
get itemCount() {
return this.items.length;
}<!-- Use kebab-case for attributes -->
<my-component data-id.bind="itemId" custom-prop.bind="value"></my-component>interface User {
id: number;
name: string;
avatar?: string;
}
export class UserProfile {
user: User = { id: 1, name: 'John' };
get avatarUrl(): string {
return this.user.avatar ?? '/default-avatar.png';
}
}<!-- Use optional chaining and fallbacks -->
<img src.bind="user?.avatar ?? defaultAvatar" alt.bind="user?.name ?? 'Unknown User'"><!-- Include ARIA attributes for screen readers -->
<button
disabled.bind="isLoading"
aria-busy.bind="isLoading & attr"
aria-label.bind="buttonLabel & attr">
${isLoading ? 'Loading...' : 'Submit'}
</button><!-- Property Binding -->
<input id.bind="inputId" />
<!-- Attribute Binding -->
<input id.attr="inputId" />// my-app.ts
export class MyApp {
inputId = 'user-input';
}// Install
npm i @aurelia/router
// Configure in main.ts
import { RouterConfiguration } from '@aurelia/router';
Aurelia
.register(RouterConfiguration.customize({
useUrlFragmentHash: false, // Clean URLs (default)
historyStrategy: 'push', // Browser history
}))
.app(MyApp)
.start();import { route } from '@aurelia/router';
@route({
routes: [
{ path: '', component: Home, title: 'Home' },
{ path: 'about', component: About, title: 'About' },
{ path: 'users/:id', component: UserDetail }
]
})
export class MyApp {}<!-- In your root component template -->
<nav>
<a href="home">Home</a>
<a href="about">About</a>
</nav>
<au-viewport></au-viewport>RouterConfiguration.customize({
useUrlFragmentHash: true // URLs like /#/about
})<!-- Using href (simple) -->
<a href="about">About</a>
<a href="users/42">User 42</a>
<!-- Using load (structured) -->
<a load="route: users; params.bind: {id: userId}">User Profile</a>import { IRouter } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
export class MyComponent {
private readonly router = resolve(IRouter);
navigateToUser(id: number) {
this.router.load(`users/${id}`);
// Or with options
this.router.load('users', {
params: { id },
queryParams: { tab: 'profile' }
});
}
}// Configure active class globally
RouterConfiguration.customize({
activeClass: 'active'
})<!-- Use with load attribute -->
<a load="home" active.bind="isHomeActive">Home</a>
<!-- Or use the configured active class -->
<a load="home">Home</a> <!-- Gets 'active' class automatically --><!-- Using href with ../ prefix -->
<a href="../sibling">Go to sibling route</a>
<!-- Using load with context -->
<a load="route: sibling; context.bind: parentContext">Sibling</a>// Programmatically
router.load('search', {
queryParams: { q: 'aurelia', page: 1 }
});
// Result: /search?q=aurelia&page=1<!-- These automatically bypass the router (no special attributes needed!) -->
<a href="https://example.com">External site</a>
<a href="mailto:[email protected]">Email</a>
<a href="tel:+1234567890">Phone</a>
<a href="//cdn.example.com/file.pdf">Protocol-relative</a>
<a href="ftp://files.example.com">FTP</a>
<!-- Also bypassed: -->
<a href="/internal" target="_blank">New tab</a>
<a href="/internal" target="other">Named target</a><!-- When URL looks internal but should bypass router -->
<a href="/api/download" external>API endpoint</a>
<a href="/old-page.html" external>Legacy HTML page</a>@route({
routes: [
{ path: 'users/:id', component: UserDetail }, // Required
{ path: 'posts/:id?', component: PostDetail }, // Optional
{ path: 'files/*path', component: FileViewer }, // Wildcard
{ path: 'items/:id{{^\\d+$}}', component: ItemDetail }, // Constrained
]
})import { IRouteViewModel, Params } from '@aurelia/router';
export class UserDetail implements IRouteViewModel {
userId: string;
canLoad(params: Params) {
this.userId = params.id;
return true;
}
}import { IRouteContext } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
export class NestedComponent {
private readonly routeContext = resolve(IRouteContext);
attached() {
const allParams = this.routeContext.getRouteParameters<{
companyId: string;
projectId: string;
userId: string;
}>({ includeQueryParams: true });
}
}{
path: 'users/:id{{^\\d+$}}', // Only numbers
component: UserDetail
}import { lifecycleHooks } from '@aurelia/runtime-html';
import { IRouteViewModel, Params, RouteNode } from '@aurelia/router';
@lifecycleHooks()
export class AuthHook {
canLoad(viewModel: IRouteViewModel, params: Params, next: RouteNode) {
const isLoggedIn = !!localStorage.getItem('authToken');
if (!isLoggedIn) {
return 'login'; // Redirect to login
}
return true; // Allow navigation
}
}@lifecycleHooks()
export class AuthorizationHook {
canLoad(viewModel: IRouteViewModel, params: Params, next: RouteNode) {
const requiredPermission = next.data?.permission;
if (requiredPermission && !this.hasPermission(requiredPermission)) {
return 'forbidden';
}
return true;
}
private hasPermission(permission: string): boolean {
// Check user permissions
return true;
}
}// In route configuration
{
path: 'admin',
component: AdminPanel,
data: { permission: 'admin' }
}import { IRouteViewModel, RouteNode } from '@aurelia/router';
export class EditForm implements IRouteViewModel {
private isDirty = false;
canUnload(next: RouteNode | null, current: RouteNode) {
if (this.isDirty) {
return confirm('You have unsaved changes. Leave anyway?');
}
return true;
}
}export class Dashboard implements IRouteViewModel {
canLoad(params: Params) {
const userRole = this.authService.getRole();
if (userRole === 'admin') {
return 'admin/dashboard';
} else if (userRole === 'user') {
return 'user/dashboard';
}
return 'login';
}
}import { IRouteViewModel, Params } from '@aurelia/router';
export class UserDetail implements IRouteViewModel {
user: User | null = null;
async loading(params: Params) {
this.user = await fetch(`/api/users/${params.id}`)
.then(r => r.json());
}
}export class Dashboard implements IRouteViewModel {
loaded(params: Params) {
// Track page view
analytics.track('page_view', { page: 'dashboard' });
// Scroll to top
window.scrollTo(0, 0);
}
}// Component hook
export class MyComponent implements IRouteViewModel {
canLoad(params: Params) {
// Runs only for this component
}
}
// Router hook (shared)
@lifecycleHooks()
export class AuthHook {
canLoad(viewModel: IRouteViewModel, params: Params) {
// Runs for all components where this is registered
}
}@route({
routes: [
{ path: 'home', component: Home },
{ path: 'about', component: About },
{ path: 'not-found', component: NotFound }
],
fallback: 'not-found' // Redirect unknown routes here
})
export class MyApp {}@route({
routes: [
{ path: '', redirectTo: 'home' },
{ path: 'about-us', redirectTo: 'about' },
{ path: 'home', component: Home },
{ path: 'about', component: About }
]
})<au-viewport name="left"></au-viewport>
<au-viewport name="right"></au-viewport><!-- Load components into both viewports -->
<a href="products@left+details/42@right">Products + Details</a>// Programmatically
router.load([
{ component: Products, viewport: 'left' },
{ component: Details, params: { id: 42 }, viewport: 'right' }
]);@route({
routes: [
{
path: 'users/:id',
component: UserLayout,
// Child routes defined in UserLayout
}
]
})
export class MyApp {}
// In UserLayout
@route({
routes: [
{ path: '', component: UserProfile },
{ path: 'posts', component: UserPosts },
{ path: 'settings', component: UserSettings }
]
})
export class UserLayout {}<!-- UserLayout template -->
<h2>User: ${userId}</h2>
<nav>
<a href="posts">Posts</a>
<a href="settings">Settings</a>
</nav>
<au-viewport></au-viewport>@route({
routes: [
{ path: 'home', component: Home },
// Dynamic import for lazy loading
{ path: 'admin', component: () => import('./admin/admin-panel') }
]
})// In route configuration
{
path: 'about',
component: About,
title: 'About Us'
}
// Programmatically
router.load('about', { title: 'Custom Title' });
// Custom title building
RouterConfiguration.customize({
buildTitle(transition) {
const titles = transition.routeTree.root.children.map(c => c.title);
return `${titles.join(' - ')} | My App`;
}
})import { IRouter } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
const router = resolve(IRouter);
// Generate path
const userPath = await router.generatePath({
component: 'users',
params: { id: 42 }
});
// Result: "/users/42"
// Use in template
<a href.bind="userPath">View User</a>RouterConfiguration.customize({
basePath: '/tenant1/app' // All routes will be prefixed
})<base href="/tenant1/app">// The router handles this automatically with historyStrategy
// To control history behavior per navigation:
router.load('page', {
historyStrategy: 'replace' // Don't create history entry
});
router.load('page', {
historyStrategy: 'push' // Create history entry (default)
});import { ICurrentRoute } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
export class MyComponent {
private readonly currentRoute = resolve(ICurrentRoute);
// Use in bindings or read it after navigation (for example in event handlers).
logCurrentRoute() {
console.log('Current path:', this.currentRoute.path);
console.log('Parameters:', this.currentRoute.parameterInformation);
}
}<!-- Internal-looking URLs that should bypass router -->
<a href="/api/download" external>API endpoint</a>
<a href="/static/old-page.html" external>Legacy page</a><a href="../sibling">Sibling Route</a>import { IRouteViewModel } from '@aurelia/router';
export class MyComponent implements IRouteViewModel {
canLoad(params: Params) { /* ... */ }
}{
path: 'users/:id',
component: UserDetail,
transitionPlan: 'invoke-lifecycles' // Re-invoke hooks
}import { IRouterEvents } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
export class MyApp {
constructor() {
const events = resolve(IRouterEvents);
events.subscribe('au:router:navigation-start', (evt) => {
console.log('Navigation started:', evt);
});
events.subscribe('au:router:navigation-end', (evt) => {
console.log('Navigation ended:', evt);
});
events.subscribe('au:router:navigation-error', (evt) => {
console.error('Navigation error:', evt);
});
}
}export interface CartItem {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
image: string;
maxQuantity: number;
}
export class ShoppingCart {
cartItems: CartItem[] = [];
// Add item to cart
addToCart(product: { id: number; name: string; price: number; image: string; maxQuantity: number }) {
const existingItem = this.cartItems.find(item => item.productId === product.id);
if (existingItem) {
// Increase quantity if item already in cart
if (existingItem.quantity < existingItem.maxQuantity) {
existingItem.quantity++;
} else {
alert(`Maximum quantity (${existingItem.maxQuantity}) reached for ${existingItem.name}`);
}
} else {
// Add new item
this.cartItems.push({
id: Date.now(), // Simple ID generation
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
image: product.image,
maxQuantity: product.maxQuantity
});
}
}
// Update item quantity
updateQuantity(item: CartItem, newQuantity: number) {
if (newQuantity <= 0) {
this.removeItem(item);
} else if (newQuantity <= item.maxQuantity) {
item.quantity = newQuantity;
} else {
item.quantity = item.maxQuantity;
alert(`Maximum quantity is ${item.maxQuantity}`);
}
}
// Increase quantity
increaseQuantity(item: CartItem) {
if (item.quantity < item.maxQuantity) {
item.quantity++;
} else {
alert(`Maximum quantity (${item.maxQuantity}) reached`);
}
}
// Decrease quantity
decreaseQuantity(item: CartItem) {
if (item.quantity > 1) {
item.quantity--;
} else {
this.removeItem(item);
}
}
// Remove item from cart
removeItem(item: CartItem) {
const index = this.cartItems.indexOf(item);
if (index > -1) {
this.cartItems.splice(index, 1);
}
}
// Clear entire cart
clearCart() {
if (confirm('Are you sure you want to clear your cart?')) {
this.cartItems = [];
}
}
// Proceed to checkout
checkout() {
console.log('Proceeding to checkout with:', this.cartItems);
alert('Proceeding to checkout...');
// In a real app, navigate to checkout page or open checkout modal
}
}import { valueConverter } from 'aurelia';
@valueConverter('currency')
export class CurrencyValueConverter {
toView(value: number, currencyCode = 'USD'): string {
if (value == null) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode
}).format(value);
}
}<import from="./currency-value-converter"></import>
<div class="shopping-cart">
<!-- Cart Header -->
<header class="cart-header">
<h1>
Shopping Cart
<span class="item-count" if.bind="cartItems.length">
(${cartItems.reduce((sum, item) => sum + item.quantity, 0)} ${cartItems.reduce((sum, item) => sum + item.quantity, 0) === 1 ? 'item' : 'items'})
</span>
</h1>
<button
if.bind="cartItems.length"
click.trigger="clearCart()"
class="clear-btn">
Clear Cart
</button>
</header>
<!-- Empty Cart State -->
<div if.bind="!cartItems.length" class="empty-cart">
<div class="empty-icon">🛒</div>
<h2>Your cart is empty</h2>
<p>Add some products to get started!</p>
</div>
<!-- Cart Items -->
<div else class="cart-content">
<!-- Cart Items List -->
<div class="cart-items">
<div
repeat.for="item of cartItems; key: id"
class="cart-item">
<!-- Product Image -->
<div class="item-image">
<img src.bind="item.image" alt.bind="item.name">
</div>
<!-- Product Details -->
<div class="item-details">
<h3 class="item-name">${item.name}</h3>
<p class="item-price">${item.price | currency:'USD'} each</p>
<!-- Quantity Controls -->
<div class="quantity-controls">
<button
click.trigger="decreaseQuantity(item)"
class="qty-btn"
title="Decrease quantity">
−
</button>
<input
type="number"
value.bind="item.quantity"
min="1"
max.bind="item.maxQuantity"
change.trigger="updateQuantity(item, item.quantity)"
class="qty-input">
<button
click.trigger="increaseQuantity(item)"
class="qty-btn"
disabled.bind="item.quantity >= item.maxQuantity"
title="Increase quantity">
+
</button>
<span class="max-qty-label" if.bind="item.quantity >= item.maxQuantity">
(max)
</span>
</div>
</div>
<!-- Item Total and Remove -->
<div class="item-actions">
<div class="item-total">
${(item.price * item.quantity) | currency:'USD'}
</div>
<button
click.trigger="removeItem(item)"
class="remove-btn"
title="Remove item">
×
</button>
</div>
</div>
</div>
<!-- Cart Summary -->
<div class="cart-summary">
<h2>Order Summary</h2>
<div class="summary-row">
<span>Subtotal:</span>
<span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}</span>
</div>
<div class="summary-row">
<span>Tax (8%):</span>
<span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 | currency:'USD'}</span>
</div>
<div class="summary-row">
<span>Shipping:</span>
<span>
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50
? 'FREE'
: (5.99 | currency:'USD')}
</span>
</div>
<div if.bind="cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) < 50 && cartItems.length" class="shipping-notice">
<small>💡 Add ${(50 - cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)) | currency:'USD'} more for free shipping!</small>
</div>
<hr class="summary-divider">
<div class="summary-row total-row">
<strong>Total:</strong>
<strong class="total-amount">
${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) +
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
</strong>
</div>
<button
click.trigger="checkout()"
disabled.bind="!cartItems.length"
class="checkout-btn">
Proceed to Checkout
</button>
</div>
</div>
</div>.shopping-cart {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.cart-header h1 {
font-size: 2rem;
color: #333;
margin: 0;
}
.item-count {
font-size: 1.2rem;
color: #666;
font-weight: normal;
}
.clear-btn {
padding: 0.5rem 1rem;
background: #fff;
border: 1px solid #dc3545;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background: #dc3545;
color: white;
}
.empty-cart {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-cart h2 {
color: #333;
margin-bottom: 0.5rem;
}
.empty-cart p {
color: #666;
}
.cart-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
}
.cart-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cart-item {
display: grid;
grid-template-columns: 100px 1fr auto;
gap: 1rem;
padding: 1rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.cart-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.item-image {
width: 100px;
height: 100px;
overflow: hidden;
border-radius: 4px;
background: #f5f5f5;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-name {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.item-price {
margin: 0;
color: #666;
font-size: 0.9rem;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.qty-btn {
width: 32px;
height: 32px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.qty-btn:hover:not(:disabled) {
background: #f0f0f0;
border-color: #007bff;
}
.qty-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qty-input {
width: 60px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
font-size: 1rem;
}
.max-qty-label {
font-size: 0.85rem;
color: #666;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.item-total {
font-size: 1.2rem;
font-weight: 600;
color: #007bff;
}
.remove-btn {
width: 32px;
height: 32px;
border: 1px solid #dc3545;
background: white;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
transition: all 0.2s;
}
.remove-btn:hover {
background: #dc3545;
color: white;
}
.cart-summary {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
height: fit-content;
position: sticky;
top: 2rem;
}
.cart-summary h2 {
margin: 0 0 1rem 0;
font-size: 1.3rem;
color: #333;
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
color: #666;
}
.shipping-notice {
background: #e3f2fd;
padding: 0.5rem;
border-radius: 4px;
margin: 0.5rem 0;
text-align: center;
}
.shipping-notice small {
color: #1976d2;
}
.summary-divider {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1rem 0;
}
.total-row {
font-size: 1.2rem;
color: #333;
margin-bottom: 1.5rem;
}
.total-amount {
color: #007bff;
font-size: 1.5rem;
}
.checkout-btn {
width: 100%;
padding: 1rem;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.checkout-btn:hover:not(:disabled) {
background: #218838;
}
.checkout-btn:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 768px) {
.cart-content {
grid-template-columns: 1fr;
}
.cart-item {
grid-template-columns: 80px 1fr;
gap: 0.75rem;
}
.item-actions {
grid-column: 1 / -1;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.cart-summary {
position: static;
}
}<!-- Item count using reduce -->
${cartItems.reduce((sum, item) => sum + item.quantity, 0)}
<!-- Subtotal calculation -->
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}
<!-- Conditional logic for free shipping -->
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 'FREE' : (5.99 | currency:'USD')}// ✓ Aurelia detects this
this.cartItems.splice(index, 1);
// ✓ Aurelia detects this
this.cartItems.push(newItem);
// ✗ Aurelia won't detect this
this.cartItems[index] = newItem; // Use splice instead<div repeat.for="item of cartItems; key: id">updateQuantity(item: CartItem, newQuantity: number) {
if (newQuantity <= 0) {
this.removeItem(item);
} else if (newQuantity <= item.maxQuantity) {
item.quantity = newQuantity;
} else {
item.quantity = item.maxQuantity;
alert(`Maximum quantity is ${item.maxQuantity}`);
}
}<div if.bind="!cartItems.length" class="empty-cart">
<!-- Empty state -->
</div>
<div else class="cart-content">
<!-- Cart items and summary -->
</div>${product.price | currency:'USD'}export class ShoppingCart {
cartItems: CartItem[] = [];
constructor() {
this.loadCart();
}
private loadCart() {
const saved = localStorage.getItem('cart');
if (saved) {
this.cartItems = JSON.parse(saved);
}
}
private saveCart() {
localStorage.setItem('cart', JSON.stringify(this.cartItems));
}
addToCart(product: any) {
// ... existing logic
this.saveCart();
}
removeItem(item: CartItem) {
// ... existing logic
this.saveCart();
}
}export class ShoppingCart {
discountCode = '';
discountPercentage = 0;
applyDiscount() {
const codes: Record<string, number> = {
'SAVE10': 10,
'SAVE20': 20,
'FREESHIP': 0 // Handle free shipping separately
};
if (codes[this.discountCode.toUpperCase()]) {
this.discountPercentage = codes[this.discountCode.toUpperCase()];
} else {
alert('Invalid discount code');
}
}
}<div class="discount-section">
<input value.bind="discountCode" placeholder="Enter discount code">
<button click.trigger="applyDiscount()">Apply</button>
</div>
<!-- In the summary, calculate discount using lambda -->
<div class="summary-row" if.bind="discountPercentage > 0">
<span>Discount (${discountPercentage}%):</span>
<span>-${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (discountPercentage / 100) | currency:'USD'}</span>
</div>
<!-- Update total to include discount -->
<div class="summary-row total-row">
<strong>Total:</strong>
<strong class="total-amount">
${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (1 - discountPercentage / 100) +
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
</strong>
</div>// cart.service.ts
import { DI } from 'aurelia';
export const ICartService = DI.createInterface<ICartService>(
'ICartService',
x => x.singleton(CartService)
);
export interface ICartService extends CartService {}
export class CartService {
cartItems: CartItem[] = [];
// ... all cart methods
}
// Use in components
import { resolve } from 'aurelia';
import { ICartService } from './cart.service';
export class ProductList {
private readonly cart = resolve(ICartService);
addToCart(product: Product) {
this.cart.addToCart(product);
}
}repeat.for Binding<ul>
<li repeat.for="item of items">
${item.name}
</li>
</ul>for (let item of items) {
// Aurelia creates DOM element for each item
console.log(item.name);
}export class MyComponent {
items = [{ name: 'John' }, { name: 'Jane' }];
addItem() {
// Aurelia detects this change and updates DOM
this.items.push({ name: 'Bob' });
}
updateFirst() {
// This change is also detected
this.items[0] = { name: 'Johnny' };
}
}<!-- Without keys: recreates all DOM on reorder -->
<div repeat.for="user of users">
<input value.bind="user.name">
</div>
<!-- With keys: preserves DOM and form state -->
<div repeat.for="user of users; key.bind: user.id">
<input value.bind="user.name">
</div><!-- Use stable, unique properties -->
<li repeat.for="product of products; key.bind: product.id">
${product.name}
</li><!-- Avoids expression evaluation -->
<li repeat.for="product of products; key: id">
${product.name}
</li><!-- For complex key logic -->
<li repeat.for="item of items; key.bind: item.category + '-' + item.id">
${item.name}
</li><div repeat.for="item of items">
<span class="index">Item ${$index + 1} of ${$length}</span>
<span class="status">
${$first ? 'First' : $last ? 'Last' : $middle ? 'Middle' : ''}
</span>
<div class="item ${$even ? 'even' : 'odd'}">
${item.name}
</div>
</div><div repeat.for="department of departments">
<h2>${department.name}</h2>
<div repeat.for="employee of department.employees">
<span>
Dept: ${$parent.department.name},
Employee #${$index + 1}: ${employee.name}
</span>
<!-- Access root context -->
<span>Company: ${$parent.$parent.companyName}</span>
</div>
</div><!-- $previous is enabled by default (disable with contextual: false) -->
<div repeat.for="item of items">
<div class="item">
${item.name}
<span if.bind="$previous !== null">
(Previous: ${$previous.name})
</span>
</div>
</div>export class ProductList {
products = [
{ category: 'Electronics', name: 'Laptop' },
{ category: 'Electronics', name: 'Mouse' },
{ category: 'Books', name: 'JavaScript Guide' },
{ category: 'Books', name: 'TypeScript Handbook' }
];
}<!-- Show category header only when it changes -->
<div repeat.for="product of products">
<h2 if.bind="product.category !== $previous?.category">
${product.category}
</h2>
<div class="product">${product.name}</div>
</div>Electronics
Laptop
Mouse
Books
JavaScript Guide
TypeScript Handbookexport class StockTracker {
prices = [
{ time: '09:00', price: 100 },
{ time: '09:01', price: 102 },
{ time: '09:02', price: 98 },
{ time: '09:03', price: 98 }
];
}<table>
<tr repeat.for="entry of prices">
<td>${entry.time}</td>
<td class="${entry.price > $previous?.price ? 'up' :
entry.price < $previous?.price ? 'down' : ''}">
$${entry.price}
<span if.bind="$previous && entry.price !== $previous.price">
${entry.price > $previous.price ? '↑' : '↓'}
</span>
</td>
</tr>
</table><!-- Multiple iterator properties separated by semicolons -->
<div repeat.for="item of items; key: id">
<div class="item-${item.id}">
${item.name}
<span if.bind="$previous">
Changed from: ${$previous.name}
</span>
</div>
</div>export class ConfigurableList {
items = [...];
showContextual = true; // Toggle contextual on/off
}<!-- Enable/disable contextual based on component state -->
<div repeat.for="item of items; contextual.bind: showContextual">
<!-- $previous is only available when contextual is true -->
</div>export class ProductList {
products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 25 }
];
sortByPrice() {
// Aurelia detects and updates DOM
this.products.sort((a, b) => a.price - b.price);
}
}<div repeat.for="product of products; key.bind: product.id">
<h3>${product.name}</h3>
<span class="price">${product.price | currency}</span>
</div>export class TagManager {
selectedTags = new Set(['javascript', 'typescript']);
toggleTag(tag: string) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
}
}<div repeat.for="tag of selectedTags">
<span class="tag">${tag}</span>
</div>export class LocalizationDemo {
translations = new Map([
['en', 'Hello'],
['es', 'Hola'],
['fr', 'Bonjour']
]);
}<!-- Destructure map entries -->
<div repeat.for="[language, greeting] of translations">
<strong>${language}:</strong> ${greeting}
</div>
<!-- Or access as entry object -->
<div repeat.for="entry of translations">
<strong>${entry[0]}:</strong> ${entry[1]}
</div><!-- Create pagination -->
<nav>
<a repeat.for="page of totalPages"
href="/products?page=${page + 1}">
${page + 1}
</a>
</nav>
<!-- Star ratings -->
<div class="rating">
<span repeat.for="star of 5"
class="star ${star < rating ? 'filled' : ''}">
★
</span>
</div>export class OrderHistory {
orders = [
{ id: 1, items: [{ name: 'Coffee', qty: 2 }] },
{ id: 2, items: [{ name: 'Tea', qty: 1 }] }
];
}<!-- Destructure objects -->
<div repeat.for="{ id, items } of orders">
Order #${id}: ${items.length} items
</div>
<!-- Destructure arrays -->
<div repeat.for="[index, value] of arrayOfPairs">
${index}: ${value}
</div><div repeat.for="user of users">
<div if.bind="user.isActive">
<strong>${user.name}</strong> - Active
</div>
<div else>
<em>${user.name}</em> - Inactive
</div>
</div><div repeat.for="category of categories">
<h2>${category.name}</h2>
<div if.bind="category.products.length > 0">
<div repeat.for="product of category.products; key.bind: product.id">
${product.name}
</div>
</div>
<p else>No products in this category</p>
</div>export class AsyncDataExample {
items: Item[] = [];
isLoading = true;
error: string | null = null;
async attached() {
try {
this.items = await this.dataService.getItems();
} catch (err) {
this.error = err.message;
} finally {
this.isLoading = false;
}
}
}<div if.bind="isLoading">
<spinner></spinner> Loading...
</div>
<div else>
<div if.bind="error">
<div class="error">Error: ${error}</div>
</div>
<div else>
<div if.bind="items.length === 0">
<p>No items found</p>
</div>
<div else>
<div repeat.for="item of items; key.bind: item.id">
${item.name}
</div>
</div>
</div>
</div>// Object keys converter
export class KeysValueConverter {
toView(obj: Record<string, any>): string[] {
return obj ? Object.keys(obj) : [];
}
}
// Object entries converter
export class EntriesValueConverter {
toView(obj: Record<string, any>): [string, any][] {
return obj ? Object.entries(obj) : [];
}
}<!-- Iterate object keys -->
<div repeat.for="key of settings | keys">
<label>${key}:</label>
<input value.bind="settings[key]">
</div>
<!-- Iterate object entries -->
<div repeat.for="[key, value] of configuration | entries">
<strong>${key}:</strong> ${value}
</div><!-- Enables efficient DOM reuse -->
<div repeat.for="item of largeList; key.bind: item.id">
${item.name}
</div><!-- Use ui-virtualization for very large collecitons of items -->
<div virtual-repeat.for="item of hugeList">
${item.name}
</div>export class ListComponent {
private subscription?: IDisposable;
attached() {
// Subscribe to external data changes
this.subscription = this.dataService.changes.subscribe(
items => this.items = items
);
}
detaching() {
// Clean up subscriptions
this.subscription?.dispose();
}
}import { IRepeatableHandler, Registration } from 'aurelia';
// Custom handler for immutable lists
class ImmutableListHandler implements IRepeatableHandler {
handles(value: unknown): boolean {
return value && typeof value === 'object' && 'size' in value && 'get' in value;
}
iterate(value: any, func: (item: unknown, index: number) => void): void {
for (let i = 0; i < value.size; i++) {
func(value.get(i), i);
}
}
}
// Register the handler
Aurelia.register(
Registration.singleton(IRepeatableHandler, ImmutableListHandler)
).app(MyApp).start();import { CollectionObserver, ICollectionObserver } from '@aurelia/runtime';
class ReactiveCustomCollection {
private _items: any[] = [];
private _observer?: ICollectionObserver;
get items() { return this._items; }
add(item: any) {
this._items.push(item);
this._observer?.handleCollectionChange(/* change details */);
}
// Implement observable pattern...
}// This won't update the DOM
this.items[0] = newItem;// These will update the DOM
this.items.splice(0, 1, newItem);
// or
this.items = [...this.items.slice(0, 0), newItem, ...this.items.slice(1)];<!-- No keys = DOM recreation -->
<div repeat.for="item of items">
<input value.bind="item.name">
</div><!-- Keys preserve DOM elements -->
<div repeat.for="item of items; key.bind: item.id">
<input value.bind="item.name">
</div>export class MyComponent {
detaching() {
// Dispose of subscriptions, timers, etc.
this.cleanup();
}
}export class ProductCatalog {
products: Product[] = [];
filteredProducts: Product[] = [];
searchTerm = '';
selectedCategory = '';
searchTermChanged() {
this.filterProducts();
}
categoryChanged() {
this.filterProducts();
}
private filterProducts() {
this.filteredProducts = this.products.filter(product => {
const matchesSearch = !this.searchTerm ||
product.name.toLowerCase().includes(this.searchTerm.toLowerCase());
const matchesCategory = !this.selectedCategory ||
product.category === this.selectedCategory;
return matchesSearch && matchesCategory;
});
}
}<div class="filters">
<input value.bind="searchTerm" placeholder="Search products...">
<select value.bind="selectedCategory">
<option value="">All Categories</option>
<option repeat.for="category of categories"
value.bind="category">${category}</option>
</select>
</div>
<div class="product-grid">
<div repeat.for="product of filteredProducts; key.bind: product.id"
class="product-card">
<img src.bind="product.image" alt.bind="product.name">
<h3>${product.name}</h3>
<p class="price">${product.price | currency}</p>
<button click.trigger="addToCart(product)">Add to Cart</button>
</div>
</div>
<div if.bind="filteredProducts.length === 0" class="no-results">
No products found matching your criteria.
</div>export class DataTable {
data: TableRow[] = [];
sortColumn = '';
sortDirection: 'asc' | 'desc' = 'asc';
sort(column: string) {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.data.sort((a, b) => {
const aVal = a[column];
const bVal = b[column];
const modifier = this.sortDirection === 'asc' ? 1 : -1;
return aVal < bVal ? -modifier : aVal > bVal ? modifier : 0;
});
}
}<table class="data-table">
<thead>
<tr>
<th repeat.for="column of columns"
click.trigger="sort(column.key)"
class="${sortColumn === column.key ? 'sorted ' + sortDirection : ''}">
${column.title}
<span if.bind="sortColumn === column.key"
class="sort-indicator">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
</tr>
</thead>
<tbody>
<tr repeat.for="row of data; key.bind: row.id">
<td repeat.for="column of columns">
${row[column.key] | column.converter}
</td>
</tr>
</tbody>
</table>interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
export class UserList {
users: User[] = [];
// Type-safe filtering
get activeUsers(): User[] {
return this.users.filter(user => user.isActive);
}
// Type-safe operations
toggleUserStatus(user: User): void {
user.isActive = !user.isActive;
}
}<!-- TypeScript provides intellisense and type checking -->
<div repeat.for="user of activeUsers; key.bind: user.id">
<span>${user.name}</span> <!-- ✓ TypeScript knows user.name exists -->
<span>${user.email}</span> <!-- ✓ Type safe -->
<button click.trigger="toggleUserStatus(user)">
${user.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>Learn how to use Shadow DOM in Aurelia components for style encapsulation and native web component features.
:host Selector:host-context() Selector::slotted() Selector::part() SelectorshadowCSS() for Shadow DOM components.::part<au-slot>: You need Aurelia's slot features like $host scope accessimport { customElement, useShadowDOM } from 'aurelia';
@customElement('my-card')
@useShadowDOM()
export class MyCard {
message = 'Hello from Shadow DOM';
}@customElement('open-element')
@useShadowDOM({ mode: 'open' })
export class OpenElement {
// External code can access: element.shadowRoot
}@customElement('closed-element')
@useShadowDOM({ mode: 'closed' })
export class ClosedElement {
// External code cannot access shadowRoot
// element.shadowRoot returns null
}import { customElement } from 'aurelia';
@customElement({
name: 'my-element',
shadowOptions: { mode: 'open' }
})
export class MyElement {}export class MyElement {
static shadowOptions = { mode: 'open' };
}import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
@customElement({
name: 'styled-card',
template: `
<div class="card">
<h2 class="title">\${title}</h2>
<div class="content">
<slot></slot>
</div>
</div>
`,
dependencies: [
shadowCSS(`
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.title {
margin: 0 0 12px 0;
color: #333;
}
.content {
color: #666;
}
`)
]
})
@useShadowDOM()
export class StyledCard {
title = 'Card Title';
}// Create a reusable stylesheet
const cardStyles = new CSSStyleSheet();
cardStyles.replaceSync(`
.card {
border: 1px solid #ddd;
padding: 16px;
}
`);
@customElement({
name: 'optimized-card',
template: '<div class="card"><slot></slot></div>',
dependencies: [shadowCSS(cardStyles)]
})
@useShadowDOM()
export class OptimizedCard {}import Aurelia from 'aurelia';
import { StyleConfiguration } from '@aurelia/runtime-html';
Aurelia
.register(
StyleConfiguration.shadowDOM({
sharedStyles: [
`
* {
box-sizing: border-box;
}
:host {
display: block;
}
`
]
})
)
.app(component)
.start();/* Inside your component's shadow DOM styles */
:host {
display: block;
border: 1px solid #e1e1e1;
padding: 16px;
}
/* Style the host when it has a specific class */
:host(.highlighted) {
background-color: #fff3cd;
border-color: #ffc107;
}
/* Style the host when it has a specific attribute */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}/* When the component is inside a dark theme container */
:host-context(.dark-theme) {
background-color: #2d3748;
color: #ffffff;
}
/* When the component is inside a specific page */
:host-context(.admin-page) {
border-left: 4px solid #dc3545;
}/* Style all slotted elements */
::slotted(*) {
margin: 8px 0;
}
/* Style specific slotted elements */
::slotted(p) {
font-size: 14px;
line-height: 1.6;
}
/* Style slotted elements with a specific class */
::slotted(.highlight) {
background-color: yellow;
}import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
@customElement({
name: 'my-card',
template: `
<div part="container" class="card">
<header part="header" class="card-header">
<slot name="header"></slot>
</header>
<div part="body" class="card-body">
<slot></slot>
</div>
<footer part="footer" class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
`,
dependencies: [
shadowCSS(`
.card { border: 1px solid #ddd; border-radius: 8px; }
.card-header { padding: 16px; background: #f8f9fa; }
.card-body { padding: 16px; }
.card-footer { padding: 12px 16px; background: #f8f9fa; }
`)
]
})
@useShadowDOM()
export class MyCard {}/* Style the exposed parts from outside the component */
my-card::part(header) {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
my-card::part(body) {
min-height: 100px;
}
my-card::part(footer) {
border-top: 1px solid #ddd;
}
/* Combine with pseudo-classes */
my-card::part(header):hover {
background: linear-gradient(135deg, #764ba2, #667eea);
}import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
@customElement({
name: 'my-button',
template: '<button><slot></slot></button>',
dependencies: [
shadowCSS(`
button {
background: var(--button-bg, #007bff);
color: var(--button-color, white);
border: none;
padding: 8px 16px;
border-radius: var(--button-radius, 4px);
cursor: pointer;
}
button:hover {
background: var(--button-hover-bg, #0056b3);
}
`)
]
})
@useShadowDOM()
export class MyButton {}<style>
/* Theme the button from outside */
.danger {
--button-bg: #dc3545;
--button-hover-bg: #c82333;
--button-radius: 8px;
}
</style>
<my-button>Default Button</my-button>
<my-button class="danger">Danger Button</my-button>import { customElement, useShadowDOM, shadowCSS, cssModules } from 'aurelia';
// CSS Module mapping (typically imported from a .module.css file via your bundler)
const styles = {
card: 'card_abc123',
header: 'header_def456',
body: 'body_ghi789'
};
@customElement({
name: 'module-card',
template: `
<div class="card">
<header class="header"><slot name="header"></slot></header>
<div class="body"><slot></slot></div>
</div>
`,
dependencies: [
cssModules(styles),
shadowCSS(`
.card_abc123 { border: 1px solid #ddd; }
.header_def456 { background: #f5f5f5; padding: 12px; }
.body_ghi789 { padding: 16px; }
`)
]
})
@useShadowDOM()
export class ModuleCard {}import { customElement, useShadowDOM } from 'aurelia';
@customElement({
name: 'modal-dialog',
template: `
<div class="modal-overlay">
<div class="modal-content">
<slot></slot>
</div>
</div>
`
})
@useShadowDOM()
export class ModalDialog {}<modal-dialog>
<h2>Modal Title</h2>
<p>Modal content goes here</p>
</modal-dialog>@customElement({
name: 'card-layout',
template: `
<div class="card">
<header class="card-header">
<slot name="header"></slot>
</header>
<div class="card-body">
<slot></slot>
</div>
<footer class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
`
})
@useShadowDOM()
export class CardLayout {}<card-layout>
<span slot="header">Card Header</span>
<p>Main content goes in the default slot</p>
<div slot="footer">
<button>Action</button>
</div>
</card-layout>@customElement({
name: 'greeting-card',
template: `
<div class="greeting">
<slot>Hello, Guest!</slot>
</div>
`
})
@useShadowDOM()
export class GreetingCard {}<!-- Uses fallback -->
<greeting-card></greeting-card>
<!-- Output: Hello, Guest! -->
<!-- Overrides fallback -->
<greeting-card>Hello, John!</greeting-card>
<!-- Output: Hello, John! --><div class="list">
<slot slotchange.trigger="handleSlotChange($event)"></slot>
</div>import { customElement, useShadowDOM } from 'aurelia';
@customElement('my-list')
@useShadowDOM()
export class MyList {
handleSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
const assignedNodes = slot.assignedNodes();
console.log('Slot changed, node count:', assignedNodes.length);
}
}// ❌ This will throw an error at runtime
@customElement('invalid-component')
@useShadowDOM()
@containerless()
export class InvalidComponent {}// ❌ This will throw a compilation error
@customElement({
name: 'broken-component',
template: '<div><slot></slot></div>'
// Missing shadowOptions!
})
export class BrokenComponent {}// ✅ Option 1: Enable Shadow DOM
@customElement({
name: 'fixed-component',
template: '<div><slot></slot></div>'
})
@useShadowDOM()
export class FixedComponent {}
// ✅ Option 2: Use <au-slot> without Shadow DOM
@customElement({
name: 'alternative-component',
template: '<div><au-slot></au-slot></div>'
})
export class AlternativeComponent {}import { customElement, useShadowDOM, shadowCSS, bindable } from 'aurelia';
@customElement({
name: 'theme-button',
template: `
<button class="btn \${variant}">
<slot></slot>
</button>
`,
dependencies: [
shadowCSS(`
.btn {
padding: var(--btn-padding, 10px 20px);
border: none;
border-radius: var(--btn-radius, 4px);
font-size: var(--btn-font-size, 16px);
cursor: pointer;
transition: all 0.2s;
}
.btn.primary {
background: var(--primary-bg, #007bff);
color: var(--primary-color, white);
}
.btn.primary:hover {
background: var(--primary-hover, #0056b3);
}
.btn.secondary {
background: var(--secondary-bg, #6c757d);
color: var(--secondary-color, white);
}
.btn.secondary:hover {
background: var(--secondary-hover, #545b62);
}
`)
]
})
@useShadowDOM()
export class ThemeButton {
@bindable variant: 'primary' | 'secondary' = 'primary';
}<style>
.custom-theme {
--primary-bg: #28a745;
--primary-hover: #218838;
--btn-radius: 20px;
}
</style>
<theme-button variant="primary">Default Primary</theme-button>
<theme-button variant="secondary">Default Secondary</theme-button>
<div class="custom-theme">
<theme-button variant="primary">Custom Themed</theme-button>
</div>import { customElement, useShadowDOM, shadowCSS, bindable } from 'aurelia';
@customElement({
name: 'info-card',
template: `
<div class="card \${expanded ? 'expanded' : ''}">
<header class="card-header" click.trigger="toggle()">
<slot name="header">Untitled Card</slot>
<span class="toggle">\${expanded ? '−' : '+'}</span>
</header>
<div class="card-body" if.bind="expanded">
<slot></slot>
</div>
<footer class="card-footer" if.bind="expanded">
<slot name="footer"></slot>
</footer>
</div>
`,
dependencies: [
shadowCSS(`
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.card-header {
background: #f8f9fa;
padding: 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.card-header:hover {
background: #e9ecef;
}
.toggle {
font-size: 24px;
font-weight: bold;
}
.card-body {
padding: 16px;
}
.card-footer {
background: #f8f9fa;
padding: 12px 16px;
border-top: 1px solid #ddd;
}
`)
]
})
@useShadowDOM()
export class InfoCard {
@bindable expanded = false;
toggle() {
this.expanded = !this.expanded;
}
}<info-card expanded.bind="true">
<strong slot="header">User Information</strong>
<div>
<p><strong>Name:</strong> John Doe</p>
<p><strong>Email:</strong> [email protected]</p>
<p><strong>Role:</strong> Developer</p>
</div>
<div slot="footer">
<button>Edit</button>
<button>Delete</button>
</div>
</info-card>
<info-card>
<span slot="header">System Status</span>
<p>All systems operational</p>
</info-card>import { customElement, useShadowDOM, shadowCSS, bindable, resolve } from 'aurelia';
import { INode } from '@aurelia/runtime-html';
@customElement({
name: 'progress-bar',
template: `
<div class="progress-container">
<div class="progress-bar" css="width: \${percentage}%"></div>
<span class="progress-text">\${percentage}%</span>
</div>
`,
dependencies: [
shadowCSS(`
.progress-container {
position: relative;
width: 100%;
height: 30px;
background: #e9ecef;
border-radius: 15px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--progress-color, #007bff);
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: bold;
color: #333;
}
`)
]
})
@useShadowDOM()
export class ProgressBar {
@bindable percentage = 0;
private host = resolve(INode);
percentageChanged(newValue: number) {
// Change color based on progress
const color = newValue < 30 ? '#dc3545' :
newValue < 70 ? '#ffc107' :
'#28a745';
this.host.style.setProperty('--progress-color', color);
}
}<progress-bar percentage.bind="25"></progress-bar>
<progress-bar percentage.bind="50"></progress-bar>
<progress-bar percentage.bind="90"></progress-bar>shadowCSS(`
.component {
color: var(--component-color, #333);
background: var(--component-bg, white);
padding: var(--component-padding, 16px);
}
`)<slot name="header">
<h2>Default Header</h2>
</slot>shadowCSS(`
.card {
background: var(--my-card-bg, white);
border: 1px solid var(--my-card-border, #ddd);
}
`)// Create once at module level, reuse across all component instances
const cardStyles = new CSSStyleSheet();
cardStyles.replaceSync(`
.card { border: 1px solid #ddd; padding: 16px; }
.card-header { font-weight: bold; }
`);
@customElement({
name: 'my-card',
template: '<div class="card"><slot></slot></div>',
dependencies: [shadowCSS(cardStyles)] // Same CSSStyleSheet instance is reused
})
@useShadowDOM()
export class MyCard {}@useShadowDOM() // defaults to open mode/**
* CSS Variables:
* --card-bg: Background color (default: white)
* --card-border: Border color (default: #ddd)
* --card-padding: Internal padding (default: 16px)
*/
@customElement('themable-card')
@useShadowDOM()
export class ThemableCard {}// my-card.ts
import { customElement, useShadowDOM, shadowCSS } from 'aurelia';
import styles from './my-card.css?inline'; // Import CSS as string (bundler-specific)
@customElement({
name: 'my-card',
template: '<div class="card"><slot></slot></div>',
dependencies: [shadowCSS(styles)] // Explicitly inject into shadow root
})
@useShadowDOM()
export class MyCard {}<!-- Text & Attributes -->
<div title.bind="tooltip">${message}</div>
<img src.bind="imageUrl" alt.bind="altText">
<!-- Form Inputs -->
<input value.bind="name">
<input value.two-way="email">
<textarea value.bind="comments"></textarea>
<!-- Boolean Attributes -->
<button disabled.bind="isLoading">Submit</button>
<input required.bind="isRequired">
<!-- Checkboxes -->
<input type="checkbox" checked.bind="isActive">
<input type="checkbox" model.bind="item.id" checked.bind="selectedIds">
<!-- Radio Buttons -->
<input type="radio" model.bind="option1" checked.bind="selectedOption">
<input type="radio" model.bind="option2" checked.bind="selectedOption">
<!-- Select -->
<select value.bind="selectedValue">
<option repeat.for="opt of options" value.bind="opt.id">${opt.name}</option>
</select><!-- Simple -->
<p>${firstName} ${lastName}</p>
<!-- Expressions -->
<p>${count * 2}</p>
<p>${isActive ? 'Active' : 'Inactive'}</p>
<!-- Optional Chaining -->
<p>${user?.profile?.name ?? 'Guest'}</p><!-- Click Events -->
<button click.trigger="save()">Save</button>
<button click.capture="handleCapture()">Capture Phase</button>
<!-- Form Events -->
<form submit.trigger="handleSubmit($event)">
<input input.trigger="onInput($event)">
<input change.trigger="onChange()">
<!-- Keyboard Events -->
<input keydown.trigger="onKeyDown($event)">
<input keyup.trigger="onKeyUp($event)">
<!-- Mouse Events -->
<div mouseover.trigger="onHover()">
<div mouseout.trigger="onLeave()"><!-- Keyboard Modifiers -->
<input keydown.trigger:ctrl="onCtrlKey()">
<input keydown.trigger:enter="onEnter()">
<input keydown.trigger:ctrl+enter="onCtrlEnter()">
<!-- Mouse Button Modifiers -->
<button click.trigger:left="onLeftClick()">
<button click.trigger:middle="onMiddleClick()">
<button click.trigger:right="onRightClick()">
<!-- Event Control -->
<a click.trigger:prevent="navigate()">Link</a>
<div click.trigger:stop="handleClick()">Stop Propagation</div>
<div click.trigger="handleSelfClick() & self">Only Direct Clicks</div><!-- Throttle (max once per interval) -->
<input input.trigger="search($event.target.value) & throttle:300">
<!-- Debounce (wait until user stops) -->
<input input.trigger="search($event.target.value) & debounce:500">
<!-- Update trigger - control when binding updates -->
<input value.bind="name & updateTrigger:'blur'">
<input value.bind="name & updateTrigger:'blur':'paste'">
<!-- Signal - manual update triggering -->
<span>${expensiveComputation | format & signal:'refresh'}</span>
<!-- Then in code: signaler.dispatchSignal('refresh') -->
<!-- Self - only handle events from the element itself -->
<div click.trigger="onClick() & self">
<button>Click me</button> <!-- Won't trigger onClick -->
</div><!-- if.bind - Removes from DOM -->
<div if.bind="isLoggedIn">Welcome back!</div>
<div else>Please log in</div>
<!-- show.bind - CSS display control -->
<div show.bind="isVisible">Toggled content</div>
<!-- hide.bind - Inverse of show.bind -->
<div hide.bind="isHidden">Hidden when true</div>
<!-- if with caching control -->
<expensive-component if="value.bind: showComponent; cache: false"></expensive-component><!-- Basic Switch -->
<template switch.bind="status">
<span case="pending">Waiting...</span>
<span case="approved">Approved!</span>
<span case="rejected">Rejected</span>
<span default-case>Unknown</span>
</template>
<!-- Multiple Cases -->
<template switch.bind="role">
<admin-panel case.bind="['admin', 'superadmin']"></admin-panel>
<user-panel case="user"></user-panel>
<guest-panel default-case></guest-panel>
</template>
<!-- Fall-through -->
<template switch.bind="level">
<span case="high" fall-through.bind="true">High priority</span>
<span case="medium">Medium priority</span>
<span default-case>Low priority</span>
</template><!-- Simple Array -->
<ul>
<li repeat.for="item of items">${item.name}</li>
</ul>
<!-- With Keys (recommended for dynamic lists) -->
<div repeat.for="user of users; key: id">
${user.name}
</div>
<!-- With Index -->
<div repeat.for="item of items">
${$index + 1}. ${item.name}
</div>
<!-- Number Range -->
<div repeat.for="i of 5">Item ${i}</div><!-- Using Contextual Properties -->
<div repeat.for="item of items">
<span class="${$even ? 'even' : 'odd'}">
${$index + 1} of ${$length}: ${item.name}
</span>
<span if.bind="$first">👑 First!</span>
<span if.bind="$last">🏁 Last!</span>
</div>
<!-- Nested Repeats with $parent -->
<div repeat.for="category of categories">
<h2>${category.name}</h2>
<div repeat.for="item of category.items">
${item.name} in ${$parent.category.name}
</div>
</div><!-- Sets -->
<div repeat.for="tag of selectedTags">
${tag}
</div>
<!-- Maps -->
<div repeat.for="[key, value] of configMap">
${key}: ${value}
</div>
<!-- Destructuring -->
<div repeat.for="{ id, name, email } of users">
${name} (${email})
</div><!-- Basic -->
<p>${price | currency}</p>
<!-- With Parameters -->
<p>${date | dateFormat:'MM/DD/YYYY'}</p>
<p>${text | truncate:50:true}</p>
<!-- Chaining -->
<p>${input | sanitize | capitalize | truncate:100}</p>
<!-- In Bindings -->
<input value.bind="searchTerm | normalize">// Create a value converter
import { valueConverter } from 'aurelia';
@valueConverter('currency')
export class CurrencyValueConverter {
toView(value: number, currencyCode = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode
}).format(value);
}
}<!-- Element Reference -->
<input ref="searchInput" value.bind="query">
<button click.trigger="searchInput.focus()">Focus Input</button>
<!-- Component (view-model) Reference -->
<my-component component.ref="myComponent"></my-component>
<button click.trigger="myComponent.refresh()">Refresh</button>
<!-- Controller Reference (for advanced use) -->
<my-component controller.ref="myComponentController"></my-component>
<!-- Custom Attribute Reference -->
<div my-tooltip.ref="tooltipInstance" my-tooltip="Hello"></div><!-- let - Local Variables (kebab-case converts to camelCase) -->
<let full-name.bind="firstName + ' ' + lastName"></let>
<h1>Hello, ${fullName}</h1>
<!-- let with to-binding-context - Assigns to view model -->
<let to-binding-context computed-value.bind="items.length * 2"></let>
<!-- Now this.computedValue is available in the view model -->
<!-- with - Scope Binding -->
<div with.bind="user">
<p>${firstName} ${lastName}</p>
<p>${email}</p>
</div>
<!-- Multiple Variables -->
<let greeting.bind="'Hello'"></let>
<let name.bind="user.name"></let>
<p>${greeting}, ${name}!</p><!-- Class Binding -->
<div class.bind="isActive ? 'active' : 'inactive'"></div>
<div class.bind="cssClasses"></div>
<!-- Single class toggle -->
<div active.class="isActive"></div>
<!-- Style Binding -->
<div style.bind="{ color: textColor, 'font-size': fontSize + 'px' }"></div>
<div style.background-color.bind="bgColor"></div>
<div style.width.bind="width + 'px'"></div><!-- Force attribute (not property) binding with & attr -->
<img src.bind="imageUrl & attr">
<!-- Useful for ARIA and data attributes -->
<button aria-label.bind="label & attr" aria-pressed.bind="isPressed & attr">
<div data-id.bind="item.id & attr">
<!-- Without & attr, bindings target DOM properties by default --><div promise.bind="fetchData()">
<span pending>Loading...</span>
<span then="data">
Loaded: ${data.title}
</span>
<span catch="error">
Error: ${error.message}
</span>
</div><!-- Spread all bindable properties from an object -->
<user-card ...$bindables="user"></user-card>
<!-- Equivalent explicit syntax -->
<user-card $bindables.spread="user"></user-card>
<!-- Shorthand (expression in attribute name) -->
<user-card ...user></user-card>// If user = { name: 'Jane', email: '[email protected]', avatarUrl: '...' }
// And UserCard has @bindable name, email, avatarUrl
// Then spread passes all matching properties automatically<!-- Using Custom Attributes -->
<div my-attribute="value"></div>
<div my-attribute.bind="dynamicValue"></div>
<!-- With Multiple Parameters -->
<div tooltip="text.bind: tooltipText; position: top; delay: 300"></div><!-- Import -->
<import from="./my-component"></import>
<import from="./utils/helpers" as="helpers"></import>
<!-- Usage -->
<my-component title.bind="pageTitle" on-save.bind="handleSave"></my-component>
<!-- Inline Component -->
<template as-custom-element="inline-component">
<bindable name="title"></bindable>
<h1>${title}</h1>
</template>
<inline-component title="Hello"></inline-component><!-- Compose with component reference -->
<au-compose component.bind="MyComponent"></au-compose>
<!-- Compose with view model and view -->
<au-compose view-model.bind="dynamicViewModel" view.bind="dynamicView"></au-compose>
<!-- Compose with model data -->
<au-compose component.bind="CardComponent" model.bind="{ title: 'Hello', content: cardContent }"></au-compose>
<!-- Compose with string path -->
<au-compose view-model="./components/dynamic-panel"></au-compose><!-- Two-way focus binding -->
<input focus.bind="isInputFocused">
<!-- Focus on condition -->
<input focus.bind="shouldFocus">
<!-- Programmatic focus via ref -->
<input ref="nameInput">
<button click.trigger="nameInput.focus()">Focus Input</button>Need to toggle visibility?
├─ Toggles frequently (e.g., dropdown, tab content)
│ └─ Use show.bind (faster, preserves state)
└─ Toggles infrequently (e.g., admin panel, authenticated content)
└─ Use if.bind (saves memory, cleans up resources)Binding to form input?
├─ YES → Use .bind (auto two-way)
└─ NO → Displaying data only?
├─ YES → Use .to-view (better performance)
└─ NO → Need to capture user changes?
├─ YES → Use .two-way
└─ NO → Static data?
└─ Use .one-timeUsing repeat.for with dynamic list?
├─ List items can be added/removed/reordered?
│ └─ YES → Always use keys (key.bind or key:)
└─ List is static or append-only?
└─ Keys optional (but recommended)<!-- Using switch for multiple states -->
<template switch.bind="state">
<div case="loading">Loading...</div>
<div case="error">Error: ${error.message}</div>
<div case="empty">No items found</div>
<div default-case>
<div repeat.for="item of items; key: id">${item.name}</div>
</div>
</template>
<!-- Or using nested if/else -->
<div if.bind="isLoading">Loading...</div>
<template else>
<div if.bind="error">Error: ${error.message}</div>
<template else>
<div if.bind="items.length === 0">No items found</div>
<div else>
<div repeat.for="item of items; key: id">${item.name}</div>
</div>
</template>
</template><input value.bind="email" class="${errors.email ? 'invalid' : ''}">
<span if.bind="errors.email" class="error">${errors.email}</span><let total.bind="items.reduce((sum, item) => sum + item.price, 0)"></let>
<p>Total: ${total | currency}</p><div class="card ${isActive ? 'active' : ''} ${isHighlighted ? 'highlight' : ''}">
Content
</div>import { resolve } from '@aurelia/kernel';
export class MyComponent {
// Properties injected via DI
private api = resolve(IApiService);
// Lifecycle methods
binding() { /* Called first */ }
attached() { /* DOM is ready */ }
detaching() { /* Cleanup */ }
}<!-- Labels for Form Inputs -->
<label for="email">Email:</label>
<input id="email" value.bind="email">
<!-- ARIA Attributes -->
<button
aria-label.bind="buttonLabel & attr"
aria-busy.bind="isLoading & attr"
disabled.bind="isLoading">
${isLoading ? 'Loading...' : 'Submit'}
</button>
<!-- Role Attributes -->
<div role="alert" if.bind="errorMessage">
${errorMessage}
</div>interface User {
id: number;
name: string;
email: string;
role: string;
status: 'active' | 'inactive' | 'pending';
lastLogin: Date;
tasksCompleted: number;
}
type SortColumn = 'name' | 'email' | 'role' | 'status' | 'lastLogin' | 'tasksCompleted';
type SortDirection = 'asc' | 'desc';
export class DataTable {
// Raw data (would normally come from API)
private allUsers: User[] = [
{
id: 1,
name: 'Alice Johnson',
email: '[email protected]',
role: 'Admin',
status: 'active',
lastLogin: new Date('2025-01-08'),
tasksCompleted: 127
},
{
id: 2,
name: 'Bob Smith',
email: '[email protected]',
role: 'User',
status: 'active',
lastLogin: new Date('2025-01-09'),
tasksCompleted: 89
},
{
id: 3,
name: 'Carol Williams',
email: '[email protected]',
role: 'Manager',
status: 'inactive',
lastLogin: new Date('2024-12-15'),
tasksCompleted: 203
},
{
id: 4,
name: 'David Brown',
email: '[email protected]',
role: 'User',
status: 'pending',
lastLogin: new Date('2025-01-07'),
tasksCompleted: 45
},
{
id: 5,
name: 'Eve Davis',
email: '[email protected]',
role: 'User',
status: 'active',
lastLogin: new Date('2025-01-09'),
tasksCompleted: 156
},
// Add more sample data...
{
id: 6,
name: 'Frank Miller',
email: '[email protected]',
role: 'Admin',
status: 'active',
lastLogin: new Date('2025-01-08'),
tasksCompleted: 312
},
{
id: 7,
name: 'Grace Wilson',
email: '[email protected]',
role: 'Manager',
status: 'active',
lastLogin: new Date('2025-01-09'),
tasksCompleted: 178
},
{
id: 8,
name: 'Henry Moore',
email: '[email protected]',
role: 'User',
status: 'inactive',
lastLogin: new Date('2024-11-20'),
tasksCompleted: 67
},
{
id: 9,
name: 'Iris Taylor',
email: '[email protected]',
role: 'User',
status: 'active',
lastLogin: new Date('2025-01-09'),
tasksCompleted: 234
},
{
id: 10,
name: 'Jack Anderson',
email: '[email protected]',
role: 'Manager',
status: 'active',
lastLogin: new Date('2025-01-08'),
tasksCompleted: 189
}
];
// Filter state
searchQuery = '';
selectedRole: string = 'all';
selectedStatus: string = 'all';
// Sort state
sortColumn: SortColumn = 'name';
sortDirection: SortDirection = 'asc';
// Pagination state
currentPage = 1;
pageSize = 5;
// Selection state
selectedRows = new Set<number>();
// Loading state
isLoading = false;
// Computed: Filtered data
get filteredUsers(): User[] {
return this.allUsers.filter(user => {
// Search filter
const query = this.searchQuery.toLowerCase();
const matchesSearch = !query ||
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query);
// Role filter
const matchesRole = this.selectedRole === 'all' ||
user.role === this.selectedRole;
// Status filter
const matchesStatus = this.selectedStatus === 'all' ||
user.status === this.selectedStatus;
return matchesSearch && matchesRole && matchesStatus;
});
}
// Computed: Sorted data
get sortedUsers(): User[] {
const sorted = [...this.filteredUsers];
sorted.sort((a, b) => {
let aVal: any = a[this.sortColumn];
let bVal: any = b[this.sortColumn];
// Handle dates
if (aVal instanceof Date) {
aVal = aVal.getTime();
bVal = (bVal as Date).getTime();
}
// Handle strings (case-insensitive)
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}
// Computed: Paginated data
get paginatedUsers(): User[] {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.sortedUsers.slice(start, end);
}
// Computed: Pagination info
get totalPages(): number {
return Math.ceil(this.sortedUsers.length / this.pageSize);
}
get totalResults(): number {
return this.sortedUsers.length;
}
get startResult(): number {
if (this.totalResults === 0) return 0;
return (this.currentPage - 1) * this.pageSize + 1;
}
get endResult(): number {
return Math.min(this.currentPage * this.pageSize, this.totalResults);
}
pageSizeChanged(newValue: number | string) {
const numeric = typeof newValue === 'string' ? Number(newValue) : newValue;
if (typeof numeric === 'number' && !Number.isNaN(numeric) && numeric !== this.pageSize) {
this.pageSize = numeric;
return;
}
this.currentPage = 1;
}
get pages(): number[] {
const pages: number[] = [];
const maxVisible = 5;
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, this.currentPage - half);
let end = Math.min(this.totalPages, start + maxVisible - 1);
// Adjust start if we're near the end
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
// Computed: Selection state
get allPageSelected(): boolean {
if (this.paginatedUsers.length === 0) return false;
return this.paginatedUsers.every(user => this.selectedRows.has(user.id));
}
get somePageSelected(): boolean {
if (this.paginatedUsers.length === 0) return false;
return this.paginatedUsers.some(user => this.selectedRows.has(user.id)) &&
!this.allPageSelected;
}
// Actions
sort(column: SortColumn) {
if (this.sortColumn === column) {
// Toggle direction
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// New column, default to ascending
this.sortColumn = column;
this.sortDirection = 'asc';
}
}
goToPage(page: number) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
}
nextPage() {
this.goToPage(this.currentPage + 1);
}
previousPage() {
this.goToPage(this.currentPage - 1);
}
toggleAllPageSelection() {
if (this.allPageSelected) {
// Deselect all on page
this.paginatedUsers.forEach(user => this.selectedRows.delete(user.id));
} else {
// Select all on page
this.paginatedUsers.forEach(user => this.selectedRows.add(user.id));
}
}
clearSelection() {
this.selectedRows.clear();
}
deleteSelected() {
if (this.selectedRows.size === 0) return;
const confirmed = confirm(`Delete ${this.selectedRows.size} user(s)?`);
if (!confirmed) return;
// Remove selected users
this.allUsers = this.allUsers.filter(user => !this.selectedRows.has(user.id));
// Clear selection
this.selectedRows.clear();
// Adjust page if needed
if (this.currentPage > this.totalPages && this.totalPages > 0) {
this.currentPage = this.totalPages;
}
}
// Reset filters
resetFilters() {
this.searchQuery = '';
this.selectedRole = 'all';
this.selectedStatus = 'all';
this.currentPage = 1;
}
// Watch for filter changes and reset to page 1
searchQueryChanged() {
this.currentPage = 1;
}
selectedRoleChanged() {
this.currentPage = 1;
}
selectedStatusChanged() {
this.currentPage = 1;
}
}<div class="data-table">
<!-- Header with filters -->
<div class="table-header">
<h2>Users</h2>
<div class="table-actions">
<button
type="button"
click.trigger="deleteSelected()"
disabled.bind="selectedRows.size === 0"
class="btn btn-danger">
Delete Selected (${selectedRows.size})
</button>
</div>
</div>
<!-- Filters -->
<div class="table-filters">
<div class="filter-group">
<label for="search">Search</label>
<input
type="text"
id="search"
value.bind="searchQuery & debounce:300"
placeholder="Search by name or email...">
</div>
<div class="filter-group">
<label for="role">Role</label>
<select id="role" value.bind="selectedRole">
<option value="all">All Roles</option>
<option value="Admin">Admin</option>
<option value="Manager">Manager</option>
<option value="User">User</option>
</select>
</div>
<div class="filter-group">
<label for="status">Status</label>
<select id="status" value.bind="selectedStatus">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
</select>
</div>
<div class="filter-group">
<label for="pageSize">Per Page</label>
<select id="pageSize" value.bind="pageSize">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
<button
type="button"
click.trigger="resetFilters()"
class="btn btn-secondary">
Reset Filters
</button>
</div>
<!-- Results summary -->
<div class="table-summary">
Showing ${startResult}-${endResult} of ${totalResults} users
<span if.bind="selectedRows.size > 0">
(${selectedRows.size} selected)
</span>
</div>
<!-- Data Table -->
<div class="table-wrapper">
<table class="table">
<thead>
<tr>
<th class="col-checkbox">
<input
type="checkbox"
checked.bind="allPageSelected"
indeterminate.bind="somePageSelected"
change.trigger="toggleAllPageSelection()"
aria-label="Select all on page">
</th>
<th
click.trigger="sort('name')"
class="sortable ${sortColumn === 'name' ? 'sorted' : ''}">
Name
<span class="sort-icon" if.bind="sortColumn === 'name'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
<th
click.trigger="sort('email')"
class="sortable ${sortColumn === 'email' ? 'sorted' : ''}">
Email
<span class="sort-icon" if.bind="sortColumn === 'email'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
<th
click.trigger="sort('role')"
class="sortable ${sortColumn === 'role' ? 'sorted' : ''}">
Role
<span class="sort-icon" if.bind="sortColumn === 'role'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
<th
click.trigger="sort('status')"
class="sortable ${sortColumn === 'status' ? 'sorted' : ''}">
Status
<span class="sort-icon" if.bind="sortColumn === 'status'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
<th
click.trigger="sort('lastLogin')"
class="sortable ${sortColumn === 'lastLogin' ? 'sorted' : ''}">
Last Login
<span class="sort-icon" if.bind="sortColumn === 'lastLogin'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
<th
click.trigger="sort('tasksCompleted')"
class="sortable ${sortColumn === 'tasksCompleted' ? 'sorted' : ''} col-number">
Tasks
<span class="sort-icon" if.bind="sortColumn === 'tasksCompleted'">
${sortDirection === 'asc' ? '↑' : '↓'}
</span>
</th>
</tr>
</thead>
<tbody>
<tr
repeat.for="user of paginatedUsers"
class="${selectedRows.has(user.id) ? 'selected' : ''}">
<td class="col-checkbox">
<input
type="checkbox"
model.bind="user.id"
checked.bind="selectedRows"
aria-label="Select ${user.name}">
</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>
<span class="badge badge-${user.role.toLowerCase()}">
${user.role}
</span>
</td>
<td>
<span class="status-${user.status}">
${user.status}
</span>
</td>
<td>${user.lastLogin | dateFormat:'MMM d, yyyy'}</td>
<td class="col-number">${user.tasksCompleted}</td>
</tr>
</tbody>
</table>
<!-- Empty state -->
<div if.bind="paginatedUsers.length === 0" class="empty-state">
<p>No users found</p>
<button
type="button"
click.trigger="resetFilters()"
class="btn btn-primary">
Clear Filters
</button>
</div>
</div>
<!-- Pagination -->
<div if.bind="totalPages > 1" class="table-pagination">
<button
type="button"
click.trigger="previousPage()"
disabled.bind="currentPage === 1"
class="btn btn-secondary"
aria-label="Previous page">
← Previous
</button>
<div class="pagination-pages">
<button
if.bind="pages[0] > 1"
type="button"
click.trigger="goToPage(1)"
class="btn btn-page">
1
</button>
<span if.bind="pages[0] > 2" class="pagination-ellipsis">...</span>
<button
repeat.for="page of pages"
type="button"
click.trigger="goToPage(page)"
class="btn btn-page ${page === currentPage ? 'active' : ''}"
aria-label="Page ${page}"
aria-current="${page === currentPage ? 'page' : undefined}">
${page}
</button>
<span if.bind="pages[pages.length - 1] < totalPages - 1" class="pagination-ellipsis">...</span>
<button
if.bind="pages[pages.length - 1] < totalPages"
type="button"
click.trigger="goToPage(totalPages)"
class="btn btn-page">
${totalPages}
</button>
</div>
<button
type="button"
click.trigger="nextPage()"
disabled.bind="currentPage === totalPages"
class="btn btn-secondary"
aria-label="Next page">
Next →
</button>
</div>
</div>.data-table {
width: 100%;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.table-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.875rem;
font-weight: 500;
}
.table-summary {
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.table-wrapper {
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table thead {
background-color: #f5f5f5;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.table th.sortable {
cursor: pointer;
user-select: none;
}
.table th.sortable:hover {
background-color: #e8e8e8;
}
.table th.sorted {
background-color: #e3f2fd;
}
.sort-icon {
margin-left: 0.25rem;
font-size: 0.75rem;
}
.col-checkbox {
width: 40px;
text-align: center;
}
.col-number {
text-align: right;
}
.table tbody tr:hover {
background-color: #f9f9f9;
}
.table tbody tr.selected {
background-color: #e3f2fd;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-admin {
background-color: #ff5722;
color: white;
}
.badge-manager {
background-color: #2196f3;
color: white;
}
.badge-user {
background-color: #4caf50;
color: white;
}
.status-active {
color: #4caf50;
}
.status-inactive {
color: #999;
}
.status-pending {
color: #ff9800;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #999;
}
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
gap: 1rem;
}
.pagination-pages {
display: flex;
gap: 0.25rem;
}
.btn-page {
min-width: 40px;
padding: 0.5rem;
}
.btn-page.active {
background-color: #2196f3;
color: white;
}
.pagination-ellipsis {
padding: 0.5rem;
color: #999;
}
/* Responsive */
@media (max-width: 768px) {
.table-filters {
flex-direction: column;
align-items: stretch;
}
.table-pagination {
flex-direction: column;
}
.table {
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.5rem;
}
}async loadUsers() {
this.isLoading = true;
const params = new URLSearchParams({
page: this.currentPage.toString(),
pageSize: this.pageSize.toString(),
search: this.searchQuery,
role: this.selectedRole,
status: this.selectedStatus,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
});
try {
const response = await fetch(`/api/users?${params}`);
const data = await response.json();
this.allUsers = data.users;
this.totalResults = data.total; // Server provides total count
} finally {
this.isLoading = false;
}
}editingRow: number | null = null;
startEdit(userId: number) {
this.editingRow = userId;
}
async saveEdit(user: User) {
await fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(user)
});
this.editingRow = null;
}
cancelEdit() {
this.editingRow = null;
// Restore original data
}visibleColumns = {
name: true,
email: true,
role: true,
status: true,
lastLogin: true,
tasksCompleted: true
};<th if.bind="visibleColumns.email">Email</th>// Get controller for the current node
CustomElement.for<T>(node: Node): ICustomElementController<T>
// Get controller with optional flag (returns null if not found)
CustomElement.for<T>(node: Node, opts: { optional: true }): ICustomElementController<T> | null
// Search parent nodes for a controller
CustomElement.for<T>(node: Node, opts: { searchParents: true }): ICustomElementController<T>
// Get controller for a named custom element
CustomElement.for<T>(node: Node, opts: { name: string }): ICustomElementController<T> | undefined
// Get controller for a named custom element, searching parents
CustomElement.for<T>(node: Node, opts: { name: string; searchParents: true }): ICustomElementController<T> | undefinedimport { CustomElement, ILogger } from 'aurelia';
// Basic usage - get controller for current node
const myElement = document.querySelector('.my-custom-element');
try {
const controller = CustomElement.for(myElement);
// You can inject ILogger in your classes for proper logging
this.logger?.info('View model:', controller.viewModel);
this.logger?.info('Element state:', controller.state);
} catch (error) {
this.logger?.error('The provided node does not host a custom element.', error);
}
// Safe retrieval without throwing errors
const optionalController = CustomElement.for(myElement, { optional: true });
if (optionalController) {
// Controller found and available for use
optionalController.viewModel.someMethod();
} else {
// No controller found, handle gracefully
this.logger?.info('Node is not a custom element');
}
// Search parent hierarchy for any custom element controller
const someInnerElement = document.querySelector('.some-inner-element');
const parentController = CustomElement.for(someInnerElement, { searchParents: true });
// parentController is the closest controller up the DOM tree
// Get controller for a specific named custom element
const namedController = CustomElement.for(myElement, { name: 'my-custom-element' });
if (namedController) {
// Found a controller for the specific element type
} else {
// The node is not hosting the named custom element type
}
// Search parents for a specific named custom element
const namedParentController = CustomElement.for(someInnerElement, {
name: 'my-custom-element',
searchParents: true
});
// Access view model properties and methods
const controller = CustomElement.for(myElement);
const viewModel = controller.viewModel;
viewModel.myProperty = 'new value';
viewModel.myMethod();
// Access lifecycle state
this.logger?.info('Current state:', controller.state);
this.logger?.info('Is activated:', controller.isActive);// Define with name and class
CustomElement.define<T>(name: string, Type: Constructable<T>): CustomElementType<T>
// Define with definition object and class
CustomElement.define<T>(def: PartialCustomElementDefinition, Type: Constructable<T>): CustomElementType<T>
// Define with definition object only (generates type)
CustomElement.define<T>(def: PartialCustomElementDefinition): CustomElementType<T>import { CustomElement } from 'aurelia';
// Basic definition with name and class
class MyCustomElement {
public message = 'Hello, World!';
public greet() {
alert(this.message);
}
}
CustomElement.define('my-custom-element', MyCustomElement);
// Definition with complete configuration object
const definition = {
name: 'advanced-element',
template: '<h1>${title}</h1><div class="content"><au-slot></au-slot></div>',
bindables: ['title', 'size'],
shadowOptions: { mode: 'open' },
containerless: false,
capture: true,
dependencies: []
};
class AdvancedElement {
public title = '';
public size = 'medium';
}
CustomElement.define(definition, AdvancedElement);
// Definition without explicit type (generates anonymous class)
const simpleDefinition = {
name: 'simple-element',
template: '<p>${text}</p>',
bindables: ['text']
};
const SimpleElementType = CustomElement.define(simpleDefinition);
// Note: Using @customElement decorator is preferred over calling define directly
@customElement('my-element')
class MyElement {
// This is equivalent to calling CustomElement.define('my-element', MyElement)
}CustomElement.getDefinition<T>(Type: Constructable<T>): CustomElementDefinition<T>import { CustomElement, ILogger } from 'aurelia';
@customElement({
name: 'my-element',
template: '${message}',
bindables: ['message']
})
class MyElement {
public message = '';
constructor(private logger: ILogger) {}
public logDefinitionInfo() {
const definition = CustomElement.getDefinition(MyElement);
this.logger.info('Element name:', definition.name); // 'my-element'
this.logger.info('Template:', definition.template);
this.logger.info('Bindables:', definition.bindables);
this.logger.info('Is containerless:', definition.containerless);
this.logger.info('Shadow options:', definition.shadowOptions);
this.logger.info('Dependencies:', definition.dependencies);
this.logger.info('Aliases:', definition.aliases);
this.logger.info('Capture mode:', definition.capture);
}
}CustomElement.find(container: IContainer, name: string): CustomElementDefinition | nullimport { CustomElement, IContainer, ILogger } from 'aurelia';
// In a custom service or component
class MyService {
constructor(private container: IContainer, private logger: ILogger) {}
public checkElementExists(elementName: string): boolean {
const definition = CustomElement.find(this.container, elementName);
return definition !== null;
}
public getElementTemplate(elementName: string): string | null {
const definition = CustomElement.find(this.container, elementName);
return definition?.template as string || null;
}
}
// Usage in template compiler or dynamic composition
class SomeComponent {
constructor(private logger: ILogger) {}
public checkDynamicElement(container: IContainer) {
const definition = CustomElement.find(container, 'my-dynamic-element');
if (definition) {
// Element is registered and available for use
this.logger.info('Found element:', definition.name);
} else {
// Element not found in current container
this.logger.warn('Element not registered');
}
}
}CustomElement.isType<T>(value: T): value is CustomElementType<T>import { CustomElement, customElement, ILogger } from 'aurelia';
@customElement('my-element')
class MyElement {}
class RegularClass {}
// Service class that performs type checking
class TypeCheckingService {
constructor(private logger: ILogger) {}
public demonstrateTypeChecking() {
// Type checking
this.logger.info('MyElement is custom element type:', CustomElement.isType(MyElement)); // true
this.logger.info('RegularClass is custom element type:', CustomElement.isType(RegularClass)); // false
this.logger.info('String is custom element type:', CustomElement.isType('string')); // false
this.logger.info('Number is custom element type:', CustomElement.isType(42)); // false
}
// Usage in dynamic scenarios
public processComponent(component: unknown) {
if (CustomElement.isType(component)) {
// Safe to use as custom element
const definition = CustomElement.getDefinition(component);
this.logger.info('Processing element:', definition.name);
} else {
this.logger.info('Not a custom element type');
}
}
}CustomElement.annotate<K extends keyof PartialCustomElementDefinition>(
Type: Constructable,
prop: K,
value: PartialCustomElementDefinition[K]
): voidimport { CustomElement } from 'aurelia';
class MyElement {}
// Manually annotate the class (decorators do this automatically)
CustomElement.annotate(MyElement, 'template', '${message}');
CustomElement.annotate(MyElement, 'bindables', ['message']);
CustomElement.annotate(MyElement, 'containerless', true);CustomElement.getAnnotation<K extends keyof PartialCustomElementDefinition>(
Type: Constructable,
prop: K
): PartialCustomElementDefinition[K] | undefinedimport { CustomElement, customElement, ILogger } from 'aurelia';
@customElement({
name: 'annotated-element',
template: '${content}'
})
class AnnotatedElement {
constructor(private logger: ILogger) {}
public logAnnotations() {
// Retrieve annotations
const template = CustomElement.getAnnotation(AnnotatedElement, 'template');
const bindables = CustomElement.getAnnotation(AnnotatedElement, 'bindables');
this.logger.info('Template:', template);
this.logger.info('Bindables:', bindables);
}
}CustomElement.generateName(): stringimport { CustomElement } from 'aurelia';
// Generate unique names for dynamic elements
const uniqueName1 = CustomElement.generateName(); // 'unnamed-1'
const uniqueName2 = CustomElement.generateName(); // 'unnamed-2'
// Use with dynamic element creation
class DynamicElement {
public data = '';
}
const DynamicElementType = CustomElement.define(uniqueName1, DynamicElement);CustomElement.generateType<P extends object = object>(
name: string,
proto?: P
): CustomElementType<Constructable<P>>import { CustomElement } from 'aurelia';
// Generate a type with custom properties and methods
const DynamicElement = CustomElement.generateType('dynamic-element', {
message: 'Hello from Dynamic Element!',
count: 0,
increment() {
this.count++;
},
showMessage() {
alert(`${this.message} Count: ${this.count}`);
}
});
// Define the generated type
CustomElement.define('dynamic-element', DynamicElement);
// Usage in templates: <dynamic-element></dynamic-element>CustomElement.createInjectable<T = any>(): InterfaceSymbol<T>import { CustomElement, resolve } from 'aurelia';
// Create injectable tokens for custom scenarios
const MyServiceToken = CustomElement.createInjectable<MyService>();
// Use in dependency injection
class MyElement {
private service = resolve(MyServiceToken);
}CustomElement.keyFrom(name: string): stringimport { CustomElement, ILogger } from 'aurelia';
class KeyGeneratorService {
constructor(private logger: ILogger) {}
public demonstrateKeyGeneration() {
const key = CustomElement.keyFrom('my-element');
this.logger.info(key); // 'au:ce:my-element' (internal format)
// Used internally for container registration/lookup
const hasElement = container.has(CustomElement.keyFrom('my-element'));
}
}@customElement(name: string)
@customElement(definition: PartialCustomElementDefinition)import { customElement } from 'aurelia';
// Simple name-based definition
@customElement('hello-world')
class HelloWorld {
public message = 'Hello, World!';
}
// Full definition object
@customElement({
name: 'advanced-component',
template: `
<h1>\${title}</h1>
<div class="content">
<au-slot></au-slot>
</div>
`,
bindables: ['title', 'theme'],
shadowOptions: { mode: 'open' },
dependencies: []
})
class AdvancedComponent {
public title = '';
public theme = 'light';
}@useShadowDOM(options?: { mode: 'open' | 'closed' })import { customElement, useShadowDOM } from 'aurelia';
@customElement('shadow-element')
@useShadowDOM({ mode: 'open' })
class ShadowElement {
// This element will render in Shadow DOM
}
// Or with default mode (open)
@customElement('shadow-element-simple')
@useShadowDOM()
class ShadowElementSimple {}@containerless()
@containerless(target: Constructable, context: ClassDecoratorContext)import { customElement, containerless } from 'aurelia';
@customElement('invisible-wrapper')
@containerless()
class InvisibleWrapper {
// This element won't create its own DOM node
// Only its content will be rendered
}
// Usage: <invisible-wrapper>content</invisible-wrapper>
// Renders: content (without the wrapper element)@capture()
@capture(filter: (attr: string) => boolean)import { customElement, capture } from 'aurelia';
@customElement('flexible-element')
@capture() // Capture all unrecognized attributes
class FlexibleElement {
// Any attribute not defined as bindable will be captured
}
@customElement('filtered-element')
@capture((attrName) => attrName.startsWith('data-'))
class FilteredElement {
// Only capture attributes that start with 'data-'
}@processContent(hook: ProcessContentHook)
@processContent(methodName: string | symbol)
@processContent() // Decorator for static methodimport { customElement, processContent, IPlatform } from 'aurelia';
@customElement('content-processor')
class ContentProcessor {
@processContent()
static processContent(node: HTMLElement, platform: IPlatform, data: Record<PropertyKey, unknown>): boolean | void {
// Modify the element's content before compilation
const children = Array.from(node.children);
children.forEach(child => {
if (child.tagName === 'SPECIAL') {
child.setAttribute('processed', 'true');
}
});
return true; // Continue with normal compilation
}
}
// Or reference a method by name
@customElement('named-processor')
@processContent('customProcessor')
class NamedProcessor {
static customProcessor(node: HTMLElement, platform: IPlatform, data: Record<PropertyKey, unknown>): boolean | void {
// Process content
return true;
}
}interface PartialCustomElementDefinition {
name?: string; // Element name (kebab-case)
template?: string | Node | null; // HTML template
bindables?: string[] | object; // Bindable properties
dependencies?: any[]; // Required dependencies
aliases?: string[]; // Alternative names
containerless?: boolean; // Render without container
shadowOptions?: { mode: 'open' | 'closed' } | null; // Shadow DOM options
hasSlots?: boolean; // Has <au-slot> elements
capture?: boolean | ((attr: string) => boolean); // Capture unbound attributes
enhance?: boolean; // Enhance existing DOM
instructions?: any[][]; // Template instructions
surrogates?: any[]; // Surrogate instructions
needsCompile?: boolean; // Requires compilation
injectable?: any; // DI token
watches?: any[]; // Property watchers
strict?: boolean; // Strict binding mode
processContent?: Function; // Content processing hook
}const elementDefinition: PartialCustomElementDefinition = {
name: 'my-component',
template: `
<h1>\${title}</h1>
<p class="description">\${description}</p>
<div class="actions">
<au-slot name="actions"></au-slot>
</div>
`,
bindables: ['title', 'description'],
shadowOptions: { mode: 'open' },
containerless: false,
hasSlots: true,
capture: false,
dependencies: [],
aliases: ['my-comp']
};interface CustomElementDefinition {
readonly Type: CustomElementType; // The element class
readonly name: string; // Element name
readonly template: string | Node | null; // Compiled template
readonly bindables: Record<string, BindableDefinition>; // Resolved bindables
readonly aliases: string[]; // Alternative names
readonly key: string; // Registry key
readonly containerless: boolean; // Container rendering mode
readonly shadowOptions: { mode: 'open' | 'closed' } | null; // Shadow DOM
readonly hasSlots: boolean; // Contains slots
readonly capture: boolean | Function; // Attribute capturing
readonly enhance: boolean; // DOM enhancement mode
readonly dependencies: any[]; // Required dependencies
readonly instructions: any[][]; // Template instructions
readonly surrogates: any[]; // Surrogate instructions
readonly needsCompile: boolean; // Compilation requirement
readonly watches: any[]; // Property watchers
readonly strict: boolean | undefined; // Strict binding mode
readonly processContent: Function | null; // Content processor
}import { alias, customElement } from '@aurelia/runtime-html';
@alias('counter-panel', 'stats-card')
@customElement({
name: 'au-counter',
template: `
<section class="counter">
<h2>\${title}</h2>
<slot></slot>
</section>
`
})
export class CounterPanel {
title = 'Visitors';
}import { AppTask, CustomElement, registerAliases } from '@aurelia/runtime-html';
import { IContainer } from '@aurelia/kernel';
export const LegacyCounterAliases = AppTask.creating(IContainer, container => {
const definition = CustomElement.getDefinition(CounterPanel);
registerAliases(['legacy-counter', 'legacy-panel'], CustomElement, definition.key, container);
});// Preferred
@customElement('my-element')
class MyElement {}
// Avoid unless in dynamic scenarios
CustomElement.define('my-element', MyElement);interface MyElementViewModel {
title: string;
count: number;
increment(): void;
}
const controller = CustomElement.for<MyElementViewModel>(element);
controller.viewModel.increment(); // Fully typed// Use optional flag when controller might not exist
const controller = CustomElement.for(element, { optional: true });
if (controller) {
// Safe to use
} else {
// Handle missing controller
}@customElement({
name: 'complex-element',
template: complexTemplate,
shadowOptions: { mode: 'open' },
bindables: ['data', 'config'],
dependencies: [SomeService, AnotherDependency]
})
class ComplexElement {}${data | valueConverter:arg & bindingBehavior:arg2}'flushInput'formValue.one-time command mid-expression.<!-- Basic usage -->
<input value.bind="searchQuery & debounce">
<!-- With parameters -->
<input value.bind="query & throttle:500">
<!-- Multiple parameters -->
<input value.bind="data & throttle:200:'signalName'">
<!-- Chaining behaviors -->
<input value.bind="text & debounce:300 & signal:'update'">
<!-- Combined with value converters -->
<span>${price | currency:'USD' & signal:'refresh'}</span><!-- All of these are equivalent -->
<input value.bind="data & throttle:200:'signal'">
<input value.bind="data & throttle :200 : 'signal'">
<input value.bind="data & throttle: 200 : 'signal'"><input type="text" value.bind="searchQuery & throttle">
<p>Searching for: ${searchQuery}</p><input type="text" value.bind="query & throttle:850"><div mousemove.trigger="mouseMoveHandler($event) & throttle"></div><input value.bind="formValue & throttle:200:'flushInput'" blur.trigger="signaler.dispatchSignal('flushInput')">import { ISignaler } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class MyApp {
formValue = '';
signaler = resolve(ISignaler); // Inject ISignaler
constructor() {}
}<input value.bind="value & throttle:200:['finishTyping', 'urgentUpdate']"><input type="text" value.bind="searchQuery & debounce"><input type="text" value.bind="searchQuery & debounce:850"><div mousemove.trigger="mouseMoveHandler($event) & debounce:500"></div><input value.bind="formValue & debounce:300:'validateInput'" blur.trigger="signaler.dispatchSignal('validateInput')">import { ISignaler } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class MyApp {
formValue = '';
signaler = resolve(ISignaler); // Inject ISignaler
constructor() {}
validateInput() {
console.log('Input validated:', this.formValue);
// Perform validation logic here
}
}<input value.bind="searchQuery & debounce:500:['search', 'validate']"><input value.bind="firstName & updateTrigger:'blur'"><input value.bind="firstName & updateTrigger:'blur':'paste'"><p>Last updated: ${lastUpdated | timeAgo & signal:'time-update'}</p>import { ISignaler } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class MyApp {
lastUpdated = new Date();
signaler = resolve(ISignaler);
constructor() {
setInterval(() => {
this.lastUpdated = new Date(); // Update the time
this.signaler.dispatchSignal('time-update'); // Signal bindings to refresh
}, 5000); // Refresh every 5 seconds
}
}<!-- Force read-only values on a child component that defaults to two-way -->
<order-line line.bind="line & toView"></order-line>
<!-- Keep track of pending edits but stop pushing DOM mutations back up -->
<textarea value.bind="draft.summary & fromView"></textarea>
<!-- Kick off an expensive computation once, never re-run -->
<span class="snapshot">${report.total & oneTime}</span>import { BindingMode, BindingModeBehavior } from '@aurelia/runtime-html';
export class DirtyCheckedBindingBehavior extends BindingModeBehavior {
public static readonly $au = { type: 'binding-behavior', name: 'dirtyChecked' } as const;
public get mode() {
return BindingMode.twoWay;
}
}
Aurelia.register(DirtyCheckedBindingBehavior);<!-- Perfect for static content -->
<span>${appVersion & oneTime}</span>
<img src.bind="logoUrl & oneTime" alt="Company Logo">
<!-- Useful in repeaters for static data -->
<div repeat.for="item of items">
<span>${item.id & oneTime}</span> <!-- ID never changes -->
<span>${item.name}</span> <!-- Name might change -->
</div><!-- Equivalent syntaxes -->
<input value.bind="dataItem & toView">
<input value.to-view="dataItem"><!-- Input updates view-model, but view-model changes don't update input -->
<input value.bind="userInput & fromView">
<!-- Equivalent to -->
<input value.from-view="userInput"><!-- Equivalent syntaxes -->
<input value.bind="userInput & twoWay">
<input value.two-way="userInput"><panel>
<header mousedown.trigger="onMouseDown($event)" ref="headerElement">
<button>Settings</button>
<button>Close</button>
</header>
</panel>export class PanelComponent {
headerElement?: HTMLElement; // Set via ref="headerElement"
onMouseDown(event: MouseEvent) {
if (event.target !== this.headerElement) {
return; // Ignore events from header's descendants
}
// Mouse down directly on the header, start panel dragging logic...
// ...
}
}<panel>
<header mousedown.trigger='onMouseDown($event) & self'>
<button class='settings'></button>
<button class='close'></button>
</header>
</panel>export class PanelComponent {
onMouseDown(event: MouseEvent) {
// No need to check event.target, 'self' behavior ensures
// this handler is only called for events directly on the header element.
// Mouse down on header, start panel dragging logic...
// ...
}
}<!-- Forces setting the 'data-value' attribute -->
<div data-value.bind="itemValue & attr">
<!-- Useful for custom attributes that need actual HTML attributes -->
<custom-element custom-attr.bind="value & attr">
<!-- CSS class binding as attribute -->
<div class.bind="cssClasses & attr">// Custom attribute that reads from HTML attribute
export class TooltipCustomAttribute {
attached() {
// This requires the actual HTML attribute to be set
const tooltipText = this.element.getAttribute('tooltip');
// Setup tooltip with tooltipText
}
}<!-- Without attr - might not work -->
<div tooltip.bind="helpText">Content</div>
<!-- With attr - ensures HTML attribute is set -->
<div tooltip.bind="helpText & attr">Content</div>import { bindingBehavior, type IBinding } from '@aurelia/runtime-html';
import type { Scope } from '@aurelia/runtime';
@bindingBehavior('logBindingContext')
export class LogBindingContextBehavior {
private originalUpdateTarget = new WeakMap<IBinding, Function>();
public bind(scope: Scope, binding: IBinding) {
// Store the original updateTarget method
const original = binding.updateTarget;
this.originalUpdateTarget.set(binding, original);
// Override updateTarget to add logging
binding.updateTarget = (value) => {
console.log('Binding context:', scope.bindingContext);
console.log('Binding value:', value);
original.call(binding, value);
};
}
public unbind(scope: Scope, binding: IBinding) {
// Restore original updateTarget method
const original = this.originalUpdateTarget.get(binding);
if (original) {
binding.updateTarget = original;
this.originalUpdateTarget.delete(binding);
}
}
}<import from="./log-binding-context-behavior.ts"></import>
<input value.bind="userName & logBindingContext">import { bindingBehavior, type IBinding } from '@aurelia/runtime-html';
import type { Scope } from '@aurelia/runtime';
@bindingBehavior('inspect')
export class InspectBindingBehavior {
private originalMethods = new WeakMap<IBinding, Function>();
public bind(scope: Scope, binding: IBinding) {
const original = binding.updateTarget;
this.originalMethods.set(binding, original);
binding.updateTarget = (value) => {
original.call(binding, value);
// Add tooltip showing current value
if (binding.target && 'title' in binding.target) {
binding.target.title = `Current value: ${JSON.stringify(value)}`;
}
};
}
public unbind(scope: Scope, binding: IBinding) {
// Restore original method
const original = this.originalMethods.get(binding);
if (original) {
binding.updateTarget = original;
this.originalMethods.delete(binding);
}
// Clear tooltip
if (binding.target && 'title' in binding.target) {
binding.target.title = '';
}
}
}<import from="./inspect-binding-behavior.ts"></import>
<input value.bind="itemName & inspect">import { bindingBehavior, type IBinding } from '@aurelia/runtime-html';
import type { Scope } from '@aurelia/runtime';
@bindingBehavior('highlightUpdates')
export class HighlightUpdatesBindingBehavior {
private originalMethods = new WeakMap<IBinding, Function>();
private timeouts = new WeakMap<IBinding, number>();
public bind(scope: Scope, binding: IBinding, highlightColor: string = 'yellow', duration: number = 500) {
const original = binding.updateTarget;
this.originalMethods.set(binding, original);
binding.updateTarget = (value) => {
original.call(binding, value);
// Clear any existing timeout
const existingTimeout = this.timeouts.get(binding);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
if (binding.target && binding.target.style) {
const originalBg = binding.target.style.backgroundColor;
binding.target.style.backgroundColor = highlightColor;
const timeout = setTimeout(() => {
binding.target.style.backgroundColor = originalBg;
this.timeouts.delete(binding);
}, duration);
this.timeouts.set(binding, timeout);
}
};
}
public unbind(scope: Scope, binding: IBinding) {
// Restore original method
const original = this.originalMethods.get(binding);
if (original) {
binding.updateTarget = original;
this.originalMethods.delete(binding);
}
// Clear any pending timeouts
const timeout = this.timeouts.get(binding);
if (timeout) {
clearTimeout(timeout);
this.timeouts.delete(binding);
}
// Reset background color
if (binding.target && binding.target.style) {
binding.target.style.backgroundColor = '';
}
}
}<import from="./highlight-updates-binding-behavior.ts"></import>
<div textContent.bind="message & highlightUpdates:'lightblue':'1000'"></div><!-- API calls -->
<input value.bind="searchTerm & debounce:500">
<!-- DOM updates -->
<div scroll.trigger="onScroll($event) & throttle:16"><!-- Use oneTime for truly static content -->
<span>${config.version & oneTime}</span>
<img src.bind="staticLogoUrl & oneTime">export class MyBehavior {
private cleanupMethods = new WeakMap();
bind(scope: Scope, binding: IBinding) {
// Setup with cleanup tracking
}
unbind(scope: Scope, binding: IBinding) {
// Always clean up to prevent memory leaks
const cleanup = this.cleanupMethods.get(binding);
cleanup?.();
this.cleanupMethods.delete(binding);
}
}<!-- Development: with debugging -->
<input value.bind="data & logBindingContext & highlightUpdates">
<!-- Production: optimized -->
<input value.bind="data & debounce:300"><!-- Real-time validation with debounce -->
<input value.bind="email & debounce:300 & signal:'validate'">
<!-- Immediate validation on blur -->
<input value.bind="email & updateTrigger:'blur'"
blur.trigger="signaler.dispatchSignal('validate')"><!-- Debounced search -->
<input value.bind="searchQuery & debounce:400">
<!-- Immediate search button -->
<button click.trigger="search() & signal:'search-now'">Search</button><!-- Time-sensitive content -->
<span>${timestamp | timeAgo & signal:'time-update'}</span>
<!-- Localized content -->
<span>${'greeting.hello' | translate & signal:'locale-change'}</span>Learn how to project content into custom elements using native slots and au-slot, and how to observe and react to slot changes.
<slot>)<slot> element), with the @children decorator@children decorator usage[au-slot] with template controllers$host<au-slot> change@slotted decorator@slotted usageslotchange bindingimport { customElement, useShadowDOM } from 'aurelia';
@customElement('au-modal')
@useShadowDOM()
export class AuModal {}<div class="modal">
<div class="modal-inner">
<slot></slot>
</div>
</div><au-modal>
<div>
<p>Modal content inside of the modal</p>
</div>
</au-modal><div class="modal">
<div class="modal-inner">
<slot name="content"></slot>
</div>
</div><au-modal>
<div slot="content">
<p>Modal content inside of the modal</p>
</div>
</au-modal><div class="modal">
<button type="button" data-action="close" class="close" aria-label="Close" click.trigger="close()" ><span aria-hidden="true">×</span></button>
<div class="modal-inner">
<slot>This is the default content shown if the user does not supply anything.</slot>
</div>
</div><slot slotchange.trigger="handleSlotChange($event.target.assignedNodes())"></slot>class MyApp {
handleSlotChange(nodes: Node[]) {
console.log('new nodes are:', nodes);
}
}import { children } from 'aurelia';
export class MyDetails {
@children('div') divs: HTMLElement[];
}<my-details>
<div>@children decorator is a good way to listen to node changes without having to deal with boilerplate yourself</div>
</my-details>export class MyItem {
...
}import { children } from 'aurelia';
import { MyItem } from './my-item';
export class MyList {
@children('my-item') items: MyItem[];
}<import from="my-list">
<my-list>
<my-item repeat.for="option of options" value.bind="option.value"></my-item>
</my-list>import { children } from 'aurelia';
import { MyItem } from './my-item';
export class MyList {
@children({
query: 'my-item',
map: (node) => node
})
items: HTMLElement[];
}static content
<au-slot>fallback content for default slot.</au-slot>
<au-slot name="s1">fallback content for s1 slot.</au-slot><!-- Usage without projection -->
<my-element></my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
static content
fallback content for default slot.
fallback content for s1 slot.
</my-element>
-->
<!-- Usage with projection -->
<my-element>
<div>d</div> <!-- using `au-slot="default"` explicitly also works. -->
<div au-slot="s1">p1</div>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
static content
<div>d</div>
<div>p1</div>
</my-element>
-->
<my-element>
<template au-slot="s1">p1</template>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
static content
fallback content for default slot.
p1
</my-element>
--><template as-custom-element="my-element">
<au-slot>dfb</au-slot>
</template>
<my-element><div au-slot>projection</div></my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<div>projection</div>
</my-element>
--><!-- Do NOT work. -->
<div au-slot></div>
<template><div au-slot></div></template>
<my-element>
<div>
<div au-slot></div>
</div>
</my-element><au-slot name="actions"></au-slot>
<ul>
<au-slot name="items"></au-slot>
</ul><my-card>
<div au-slot="actions" if.bind="showActions">
<button click.trigger="dismiss()">Dismiss</button>
</div>
<template au-slot="items" repeat.for="item of items">
<li>${item}</li>
</template>
</my-card><au-slot>dfb</au-slot>
<au-slot name="s1">s1fb</au-slot>
<au-slot name="s2">s2fb</au-slot>import { resolve } from 'aurelia';
import { IAuSlotsInfo } from '@aurelia/runtime-html';
class MyElement {
private readonly slotInfo = resolve(IAuSlotsInfo);
binding() {
console.log(this.slotInfo.projectedSlots);
}
}<!-- my_element_instance_1 -->
<my-element>
<div au-slot="default">dp</div>
<div au-slot="s1">s1p</div>
</my-element>
<!-- my_element_instance_2 -->
<my-element></my-element>// my_element_instance_1
['default', 's1']
// my_element_instance_2
[]<my-element>
<div au-slot="s1">${message}</div>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<div>outer</div>
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1">${message}</au-slot>export class MyElement {
public readonly message: string = 'inner';
}<my-element>
<foo-bar au-slot="s1" foo.bind="message"></foo-bar>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<foo-bar>outer</foo-bar>
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1">${message}</au-slot>export class MyElement {
public readonly message: string = 'inner';
}${foo}export class FooBar {
@bindable public foo: string;
}<my-element></my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
inner
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1">${message}</au-slot>export class MyElement {
public readonly message: string = 'inner';
}<my-element></my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<foo-bar>inner</foo-bar>
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1">
<foo-bar foo.bind="message"></foo-bar>
</au-slot>export class MyElement {
public readonly message: string = 'inner';
}${foo}export class FooBar {
@bindable public foo: string;
}<my-element>
<div au-slot="s1">${$host.message}</div>
<div au-slot="s2">${message}</div>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<div>inner</div>
<div>outer</div>
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1"></au-slot>
<au-slot name="s2"></au-slot>export class MyElement {
public readonly message: string = 'inner';
}<my-element>
<foo-bar au-slot="s1" foo.bind="$host.message"></foo-bar>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<foo-bar>inner</foo-bar>
</my-element>
-->export class MyApp {
public readonly message: string = 'outer';
}<au-slot name="s1"></au-slot>export class MyElement {
public readonly message: string = 'inner';
}${foo}export class FooBar {
@bindable public foo: string;
}<template as-custom-element="my-element">
<bindable name="people"></bindable>
<au-slot name="grid">
<au-slot name="header">
<h4>First Name</h4>
<h4>Last Name</h4>
</au-slot>
<template repeat.for="person of people">
<au-slot name="content" expose.bind="{ person, $event, $odd, $index }">
<div>${person.firstName}</div>
<div>${person.lastName}</div>
</au-slot>
</template>
</au-slot>
</template>
<my-element people.bind="people">
<template au-slot="header">
<h4>Meta</h4>
<h4>Surname</h4>
<h4>Given name</h4>
</template>
<template au-slot="content">
<div>${$host.$index}-${$host.$even}-${$host.$odd}</div>
<div>${$host.person.lastName}</div>
<div>${$host.person.firstName}</div>
</template>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<h4>Meta</h4> <h4>Surname</h4> <h4>Given name</h4>
<div>0-true-false</div> <div>Doe</div> <div>John</div>
<div>1-false-true</div> <div>Mustermann</div> <div>Max</div>
</my-element>
-->export class MyApp {
public readonly people: Person[] = [
new Person('John', 'Doe'),
new Person('Max', 'Mustermann'),
];
}
class Person {
public constructor(
public firstName: string,
public lastName: string,
) { }
}<au-slot name="s1">s1</au-slot>
<au-slot name="s2">s2</au-slot><my-element>
<div au-slot="s2">p20</div>
<div au-slot="s1">p11</div>
<div au-slot="s2">p21</div>
<div au-slot="s1">p12</div>
</my-element>
<!-- Rendered (simplified): -->
<!--
<my-element>
<div>p11</div>
<div>p12</div>
<div>p20</div>
<div>p21</div>
</my-element>
--><au-slot name="header"></au-slot>
<au-slot name="content"></au-slot><my-tabs>
<h3 au-slot="header">Tab1</h3>
<div au-slot="content">Tab1 content</div>
<h3 au-slot="header">Tab2</h3>
<div au-slot="content">Tab2 content</div>
<!--...-->
</my-tabs><let details-shown.bind="false"></let>
<au-slot name="name"></au-slot>
<button click.trigger="detailsShown=!detailsShown">Toggle details</button>
<div if.bind="detailsShown">
<au-slot name="name"></au-slot>
<au-slot name="role"></au-slot>
<au-slot name="details"></au-slot>
</div><person-card>
<span au-slot="name"> John Doe </span>
<span au-slot="role"> Role1 </span>
<span au-slot="details"> Lorem ipsum </span>
</person-card><my-summary>
<p>This is a demo of the @slotted decorator</p>
<p>It can get all the "p" elements with a simple decorator</p>
</my-summary><p>Heading text</p>
<div>
<au-slot></au-slot>
</div>import { slotted } from 'aurelia';
export class MySummaryElement {
@slotted('p') paragraphs // assert paragraphs.length === 2
}import { slotted } from 'aurelia';
export class MySummaryElement {
@slotted('p') paragraphs // assert paragraphs.length === 2
paragraphsChanged(ps: HTMLParagraphElement[]) {
// do things
}
}<p>Heading text</p>
<div>
<au-slot></au-slot>
</div><my-summary>
<p>This is a demo of the @slotted decorator</p>
<p>It can get all the "p" elements with a simple decorator</p>
</my-summary><my-summary>
<p>This is a demo of the @slotted decorator</p>
<p if.bind="describeMore">It can get all the "p" elements with a simple decorator</p>
</my-summary><p>Heading text</p>
<div>
<au-slot slotchange.bind="onContentChange"></au-slot>
<au-slot slotchange.bind="(name, nodes) => doSomething(name, nodes)"></au-slot>
</div>import { slotted } from 'aurelia';
export class MySummaryElement {
@slotted('p') paragraphs // assert paragraphs.length === 1
onContentChange = (name: string, nodes: Node[]) => {
// handle the new set of nodes here
console.assert(this === undefined);
}
doSomething(name: string, nodes: Node[]) {
console.assert(this instanceof MySummaryElement);
}
}SCENARIO: Parent component with 2 children activates
═══════════════════════════════════════════════════════════════
Timeline:
─────────────────────────────────────────────────────────────
Time Parent Child-1 Child-2
──── ────── ─────── ───────
0 constructor()
1 define()
2 hydrating()
3 hydrated()
created() ←──────── created() ←─────── created()
│ │
└─ children first ─┘
4 binding() ──────┐
↓ (if async, │
blocks children)│
│
5 ← resolve ──────┘
bind() (connects bindings)
6 attaching() ────┐
_attach() DOM ──┤ binding() ────┐ binding() ────┐
│ │ │
│ bind() │ bind() │
│ │ │
│ attaching() ──┤ attaching() ───┤
│ _attach() DOM │ _attach() DOM │
│ │ │
┌───┴────────────────┴──────────────────┘
│ (parent's attaching() and
│ children activation run in PARALLEL)
│
7 └─→ Wait for all to complete
attached() ←───── attached() ←──── attached()
│ │ │
└─ children first (bottom-up) ─────┘
8 ACTIVATED
DETAILED ACTIVATION FLOW
═══════════════════════════════════════════════════════════
┌────────────────────────────────────────────────────┐
│ CONSTRUCTION PHASE (Top ➞ Down) │
├────────────────────────────────────────────────────┤
│ │
│ Parent.constructor() │
│ → Child1.constructor() │
│ → Child2.constructor() │
│ │
│ Parent.define() │
│ → Child1.define() │
│ → Child2.define() │
│ │
│ Parent.hydrating() │
│ → Child1.hydrating() │
│ → Child2.hydrating() │
│ │
│ Parent.hydrated() │
│ → Child1.hydrated() │
│ → Child2.hydrated() │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ CREATED PHASE (Bottom ➞ Up) │
├────────────────────────────────────────────────────┤
│ │
│ Child1.created() │
│ Child2.created() │
│ → Parent.created() ← After all children │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ BINDING PHASE (Top ➞ Down, Blocks Children) │
├────────────────────────────────────────────────────┤
│ │
│ Parent.binding() │
│ ↓ (if async, children wait) │
│ ↓ │
│ [ await parent.binding() ] │
│ ↓ │
│ Parent.bind() - connects bindings to scope │
│ ↓ │
│ Child1.binding() │
│ ↓ │
│ [ await child1.binding() ] │
│ ↓ │
│ Child1.bind() │
│ │
│ Child2.binding() │
│ ↓ │
│ [ await child2.binding() ] │
│ ↓ │
│ Child2.bind() │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ BOUND PHASE (Bottom ➞ Up) │
├────────────────────────────────────────────────────┤
│ │
│ Child1.bound() │
│ Child2.bound() │
│ → Parent.bound() ← After children │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ ATTACHING PHASE (Parallel!) │
├────────────────────────────────────────────────────┤
│ │
│ Parent.attaching() activatingStack = 1 │
│ ↓ ↓ │
│ Parent._attach() │ │
│ → Append to DOM │ │
│ │ │
│ [ Both run in PARALLEL ] │ │
│ ├─ await parent.attaching() │ │
│ └─ Child activation ───────────┘ │
│ ├─ Child1.binding() activatingStack++ │
│ ├─ Child1.bind() │
│ ├─ Child1.bound() │
│ ├─ Child1.attaching() │
│ ├─ Child1._attach() │
│ │ → Append to DOM │
│ │ │
│ ├─ Child2.binding() activatingStack++ │
│ ├─ Child2.bind() │
│ ├─ Child2.bound() │
│ ├─ Child2.attaching() │
│ └─ Child2._attach() │
│ → Append to DOM │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ ATTACHED PHASE (Bottom ➞ Up) │
├────────────────────────────────────────────────────┤
│ │
│ _leaveActivating() called on each child │
│ activatingStack-- for each │
│ │
│ When activatingStack === 0: │
│ Child1.attached() activatingStack-- │
│ Child2.attached() activatingStack-- │
│ → Parent.attached() activatingStack-- │
│ ↓ │
│ activatingStack === 0 │
│ → state = activated │
│ │
└────────────────────────────────────────────────────┘
KEY IMPLEMENTATION DETAILS
═══════════════════════════════════════════════════════════
1. _enterActivating() increments activatingStack
- Called when starting binding phase
- Recursively increments parent's stack
2. Parent's attaching() runs in PARALLEL with children
- Children start activating while parent is still attaching
- This allows for better performance
3. attached() only called when stack === 0
- _leaveActivating() decrements stack
- When stack reaches 0, attached() is invoked
- This ensures bottom-up execution
4. binding() can block
- If it returns a Promise, children wait
- This is why it's marked "blocks children" in docsSCENARIO: Parent with 2 children deactivates
═══════════════════════════════════════════════════════════
Timeline:
─────────────────────────────────────────────────────────────
Time Parent Child-1 Child-2
──── ────── ─────── ───────
0 deactivate() ───┐
│
1 └──→ deactivate() ──→ deactivate()
│ │
(children first) │
│ │
2 detaching() ←─────┘
│
(builds linked list)
│
3 detaching() ←────────┘
│
(initiator collects all)
│
4 _leaveDetaching()
└─→ detachingStack === 0
5 removeNodes() ──┐
├─→ removeNodes()
└─→ removeNodes()
(DOM removed from all)
6 unbinding() ────┐
├─→ unbinding()
└─→ unbinding()
(process linked list)
7 unbind() ───────┐
├─→ unbind()
└─→ unbind()
DEACTIVATED
DETAILED DEACTIVATION FLOW
═══════════════════════════════════════════════════════════
┌────────────────────────────────────────────────────┐
│ CHILD DEACTIVATION (Children First) │
├────────────────────────────────────────────────────┤
│ │
│ Parent.deactivate() called │
│ ↓ │
│ state = deactivating │
│ ↓ │
│ for each child: │
│ child.deactivate(initiator, parent) │
│ ↓ │
│ Child1.deactivate() ────┐ │
│ Child2.deactivate() ────┤ │
│ │ │
└───────────────────────────────┼────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ DETACHING PHASE (Children First, Build List) │
├────────────────────────────────────────────────────┤
│ │
│ Child1.detaching() detachingStack++ │
│ ↓ (await if async) │
│ Add Child1 to linked list │
│ │
│ Child2.detaching() detachingStack++ │
│ ↓ (await if async) │
│ Add Child2 to linked list │
│ │
│ Parent.detaching() detachingStack++ │
│ ↓ (await if async) │
│ Add Parent to linked list │
│ │
│ Linked list: Child1 → Child2 → Parent │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ DETACH PHASE (Initiator Processes All) │
├────────────────────────────────────────────────────┤
│ │
│ Initiator._leaveDetaching() │
│ ↓ │
│ detachingStack-- │
│ ↓ │
│ When stack === 0: │
│ Process linked list: │
│ ↓ │
│ Parent.removeNodes() │
│ Child1.removeNodes() │
│ Child2.removeNodes() │
│ ↓ │
│ (DOM physically removed) │
│ │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ UNBINDING PHASE (Process List, Children First) │
├────────────────────────────────────────────────────┤
│ │
│ Walk linked list (Child1 → Child2 → Parent): │
│ │
│ Child1.unbinding() unbindingStack++ │
│ ↓ (await if async) │
│ Child1.unbind() │
│ → disconnect bindings │
│ → scope = null │
│ │
│ Child2.unbinding() unbindingStack++ │
│ ↓ (await if async) │
│ Child2.unbind() │
│ → disconnect bindings │
│ → scope = null │
│ │
│ Parent.unbinding() unbindingStack++ │
│ ↓ (await if async) │
│ Parent.unbind() │
│ → disconnect bindings │
│ → scope.parent = null │
│ → state = deactivated │
│ │
└────────────────────────────────────────────────────┘
KEY IMPLEMENTATION DETAILS
═══════════════════════════════════════════════════════════
1. Children deactivate first
- Parent calls deactivate on each child
- Children process before parent continues
2. Linked list built during detaching
- Each component adds itself to the list
- List maintains deactivation order
3. Only initiator processes the list
- Non-initiator components just add themselves
- Initiator handles all DOM removal and unbinding
4. removeNodes() called before unbinding
- DOM physically removed first
- Then bindings are disconnected
5. unbinding() processed via linked list
- Walks the list in order
- Calls unbinding hooks sequentially
PARALLEL DETACHING
═══════════════════════════════════════════════════════════
When detaching() returns a Promise:
Parent.detaching() ─────┐
├─ await (parallel)
Child1.detaching() ─────┤
├─ await (parallel)
Child2.detaching() ─────┘
All detaching() hooks await in PARALLEL, then:
→ removeNodes() on all
→ unbinding() in sequence via linked list
→ unbind() completes deactivation
This allows exit animations to run simultaneously!ACTIVATION STACK MECHANISM
═══════════════════════════════════════════════════════════
Purpose: Ensure attached() only fires after ALL children are ready
private _activatingStack: number = 0;
_enterActivating() {
++this._activatingStack; // Increment
if (this.$initiator !== this) {
this.parent._enterActivating(); // Propagate up
}
}
_leaveActivating() {
if (--this._activatingStack === 0) { // Decrement
// Stack is 0, all children done!
this.attached(); // Call attached()
this.state = activated;
}
if (this.$initiator !== this) {
this.parent._leaveActivating(); // Propagate up
}
}
STACK TIMELINE (Parent + 2 Children)
═══════════════════════════════════════════════════════════
Time Action Parent Stack
──── ────── ────────────
0 Parent.activate() 0
1 _enterActivating() (binding) 1 (Enter)
2 Parent.binding() completes 1
3 Parent.bind() 1
4 Parent.attaching() 1
5 _enterActivating() (attaching) 2 (Enter again)
6 Child1 starts activating 3 (Child enters)
7 Child1 attaching 3
8 Child2 starts activating 4 (Child enters)
9 Child2 attaching 4
10 Parent.attaching() completes 4
11 _leaveActivating() (attaching) 3 (Leave)
12 Child1.attaching() completes 3
13 Child1 _leaveActivating() 2 (Child leaves)
14 Child1.attached() 2 (Stack > 0, can't call parent yet)
15 Child2.attaching() completes 2
16 Child2 _leaveActivating() 1 (Child leaves)
17 Child2.attached() 1
18 _leaveActivating() (binding) 0 (Stack === 0!)
19 Parent.attached() ----------------- 0 (NOW parent can fire)
state = activated
DETACHING STACK MECHANISM
═══════════════════════════════════════════════════════════
Purpose: Await all detaching() Promises before removing DOM
private _detachingStack: number = 0;
_enterDetaching() {
++this._detachingStack;
}
_leaveDetaching() {
if (--this._detachingStack === 0) {
// All detaching() complete!
this.removeNodes(); // Now safe to remove DOM
// Process unbinding via linked list...
}
}
DETACHING STACK TIMELINE
═══════════════════════════════════════════════════════════
Time Action Stack
──── ────── ─────
0 Parent.deactivate() 0
1 Child1.deactivate() 0
2 Child1.detaching() -> Promise 0
3 _enterDetaching() 1 (Track Promise)
4 Child2.deactivate() 1
5 Child2.detaching() -> Promise 1
6 _enterDetaching() 2 (Track Promise)
7 Parent.detaching() -> Promise 2
8 _enterDetaching() 3 (Track Promise)
9 Child1 Promise resolves 3
10 _leaveDetaching() 2 (Done)
11 Child2 Promise resolves 2
12 _leaveDetaching() 1 (Done)
13 Parent Promise resolves 1
14 _leaveDetaching() 0 (Stack === 0!)
removeNodes() on all -------------- 0 (Now safe)
unbinding() on all ---------------- 0
WHY STACKS ARE NECESSARY
═══════════════════════════════════════════════════════════
Problem without stacks:
- Parent's attached() might fire before children ready
- DOM might be removed while animations still running
- Race conditions between parent and children
Solution with stacks:
- attached() only fires when stack === 0 (all done)
- DOM only removed when all detaching() complete
- Clean coordination between parent and children
MULTIPLE ENTER/LEAVE CALLS
═══════════════════════════════════════════════════════════
A single controller can call _enterActivating() multiple times:
1. Once for binding phase
2. Once for attaching phase
This is intentional! The stack tracks ALL pending work:
- Parent's own lifecycle phases
- Each child's activation
When stack reaches 0, everything is truly done.SYNC VS ASYNC BINDING
═══════════════════════════════════════════════════════════
Synchronous binding():
──────────────────────────────────────────
export class MyComponent {
binding() {
this.data = setupData(); // ← Sync
}
}
Timeline:
Parent.binding() ──┐
├─ immediate
Parent.bind() ─────┘
↓
Child.binding() ───┐
├─ immediate
Child.bind() ──────┘
Total: ~0ms blocking time
Asynchronous binding():
──────────────────────────────────────────
export class MyComponent {
async binding() {
this.data = await fetch('/api/data'); // ← Async
}
}
Timeline:
Parent.binding() ──┐
├─ await ─────────────┐ (500ms)
│ │
│ (children blocked) │
│ │
└─────────────────────┘
Parent.bind() ─────┘
↓
Child.binding() ───┐ ← Only starts after parent resolves
├─ immediate
Child.bind() ──────┘
Total: ~500ms blocking time
REAL-WORLD IMPACT
═══════════════════════════════════════════════════════════
Bad - Blocks children unnecessarily:
export class Parent {
async binding() {
// This blocks children for 1 second!
await delay(1000);
this.data = 'loaded';
}
}
Good - Use loading() instead:
export class Parent {
async loading() {
// Children can start while this runs
await delay(1000);
this.data = 'loaded';
}
binding() {
// Sync, doesn't block children
}
}
ATTACHING() DOESN'T BLOCK CHILDREN
═══════════════════════════════════════════════════════════
Key difference from binding():
export class Parent {
async attaching() {
// This runs in PARALLEL with children!
await animateIn();
}
}
Timeline:
Parent.attaching() ────┐
├─ async animation (parallel)
Child activation ──────┤
├─ runs simultaneously
Both complete ─────────┘
↓
Parent.attached()
Child.attached()
Note: attaching() and child activation run in parallel
ATTACHED() AWAITS ATTACHING()
═══════════════════════════════════════════════════════════
export class MyComponent {
async attaching() {
await animateIn(); // ← Async
}
attached() {
// Only called AFTER attaching() resolves
console.log('Animation complete!');
}
}
Timeline:
attaching() ──────┐
├─ await animation
└──→ [ animation completes ]
↓
attached() ← Called now
This ensures you can safely measure DOM in attached()
DETACHING() PARALLEL BEHAVIOR
═══════════════════════════════════════════════════════════
Detaching hooks await in PARALLEL:
export class Parent {
async detaching() {
await this.animateOut(); // 500ms
}
}
export class Child1 {
async detaching() {
await this.animateOut(); // 300ms
}
}
export class Child2 {
async detaching() {
await this.animateOut(); // 400ms
}
}
Timeline:
Parent.detaching() ────────┐ (500ms)
Child1.detaching() ─────┐ │ (300ms)
Child2.detaching() ──────┤ │ (400ms)
│ │
│ │ (all run in parallel)
│ │
All complete ────────────┴──┘
↓ (after 500ms - longest)
removeNodes()
unbinding()
Total time: 500ms (not 1200ms!)
PROMISE REJECTION HANDLING
═══════════════════════════════════════════════════════════
If a lifecycle hook Promise rejects:
export class MyComponent {
async binding() {
throw new Error('Failed to load data');
}
}
Behavior:
ret.catch((err: Error) => {
this._reject(err); // Propagates to controller.$promise
});
The activation aborts and the error propagates to the parent.
The component will NOT be activated.
BEST PRACTICES
═══════════════════════════════════════════════════════════
DO use async in attaching() for animations
(runs in parallel, doesn't block)
DO use async in detaching() for exit animations
(all run in parallel)
AVOID async in binding() unless necessary
(blocks all children from starting)
DO use loading() for data fetching
(router lifecycle, doesn't block children)
AVOID long-running operations in binding()
(delays entire component tree activation)PITFALL #1: Memory Leaks from Event Listeners
═══════════════════════════════════════════════════════════
BAD - Leaks memory:
export class MyComponent {
attached() {
window.addEventListener('resize', this.handleResize);
}
// Missing cleanup!
}
GOOD - Properly cleaned up:
export class MyComponent {
attached() {
window.addEventListener('resize', this.handleResize);
}
detaching() {
window.removeEventListener('resize', this.handleResize);
}
}
BETTER - Use bound method:
export class MyComponent {
private handleResize = () => { /* ... */ };
attached() {
window.addEventListener('resize', this.handleResize);
}
detaching() {
window.removeEventListener('resize', this.handleResize);
}
}
PITFALL #2: Accessing DOM Before It's Ready
═══════════════════════════════════════════════════════════
BAD - DOM not ready:
export class MyComponent {
binding() {
// DOM not attached yet!
const width = this.element.offsetWidth; // Might be 0
}
}
GOOD - Wait for attached:
export class MyComponent {
attached() {
// DOM is now in document and laid out
const width = this.element.offsetWidth; // Correct!
}
}
Why: binding() happens before DOM is attached.
Use attached() for DOM measurements.
PITFALL #3: Blocking Children with Slow binding()
═══════════════════════════════════════════════════════════
BAD - Blocks entire tree:
export class Parent {
async binding() {
// This delays ALL children for 2 seconds!
this.data = await slowApiCall(); // 2000ms
}
}
GOOD - Use loading() or attached():
export class Parent {
async loading() {
// Children can start while this runs
this.data = await slowApiCall();
}
binding() {
// Quick, synchronous setup only
}
}
Or if not using router:
export class Parent {
binding() {
// Synchronous setup
}
attached() {
// Async data loading after activation
void this.loadData();
}
private async loadData() {
this.data = await slowApiCall();
}
}
PITFALL #4: Not Awaiting Async Hooks
═══════════════════════════════════════════════════════════
BAD - Missing await:
export class MyComponent {
detaching() {
this.animateOut(); // Missing await/return!
}
private async animateOut() {
await animation.play();
}
}
// Animation cut short because DOM removed immediately!
GOOD - Properly awaited:
export class MyComponent {
detaching() {
return this.animateOut(); // Return the Promise
}
private async animateOut() {
await animation.play();
}
}
// Framework waits for animation before removing DOM
PITFALL #5: Heavy Work in Constructor
═══════════════════════════════════════════════════════════
BAD - Premature work:
export class MyComponent {
@bindable data: any;
constructor() {
// data is undefined! Bindables not set yet
this.processData(this.data); // undefined!
}
}
GOOD - Wait for binding:
export class MyComponent {
@bindable data: any;
binding() {
// Bindables are now set
this.processData(this.data); // Correct!
}
}
Rule: Constructor runs before bindables are set.
Use binding() or later hooks to access bindables.
PITFALL #6: Forgetting dispose() for Long-Lived Resources
═══════════════════════════════════════════════════════════
BAD - Resource leak:
export class MyComponent {
private subscription: Subscription;
attached() {
this.subscription = eventAggregator.subscribe('event', this.handler);
}
detaching() {
this.subscription.dispose(); // Not enough!
}
}
// If component is cached (repeat.for), subscription persists!
GOOD - Clean up in dispose:
export class MyComponent {
private subscription: Subscription;
attached() {
this.subscription = eventAggregator.subscribe('event', this.handler);
}
detaching() {
// Short-lived cleanup
}
dispose() {
// Permanent cleanup
this.subscription?.dispose();
}
}
When to use each:
- detaching(): Temporary deactivation (might reactivate)
- dispose(): Permanent cleanup (never coming back)
PITFALL #7: Modifying @observable During Deactivation
═══════════════════════════════════════════════════════════
BAD - Triggers bindings during teardown:
export class MyComponent {
@observable isActive: boolean = true;
unbinding() {
this.isActive = false; // Triggers change handlers!
}
}
// Can cause errors if bindings partially disconnected
GOOD - Set state before unbinding:
export class MyComponent {
@observable isActive: boolean = true;
detaching() {
// Bindings still active, safe to modify
this.isActive = false;
}
unbinding() {
// Just cleanup, no state changes
}
}
PITFALL #8: Not Handling Deactivation During Activation
═══════════════════════════════════════════════════════════
BAD - Race condition:
export class MyComponent {
private data: any;
async binding() {
this.data = await fetch('/api/slow'); // 5 seconds
// User navigates away after 1 second...
this.doSomething(this.data); // Component might be gone!
}
}
GOOD - Check state:
export class MyComponent {
private data: any;
private isActive = true;
async binding() {
this.data = await fetch('/api/slow');
if (!this.isActive) {
return; // Don't continue if deactivated
}
this.doSomething(this.data);
}
unbinding() {
this.isActive = false;
}
}
BETTER - Use AbortController:
export class MyComponent {
private abortController = new AbortController();
async binding() {
try {
const data = await fetch('/api/slow', {
signal: this.abortController.signal
});
this.doSomething(data);
} catch (err) {
if (err.name === 'AbortError') {
return; // Deactivated, ignore
}
throw err;
}
}
unbinding() {
this.abortController.abort();
}
}
PITFALL #9: Incorrect Parent-Child Communication Timing
═══════════════════════════════════════════════════════════
BAD - Child calls parent too early:
export class Child {
@bindable onReady: () => void;
binding() {
this.onReady(); // Parent might not be bound yet!
}
}
GOOD - Wait for attached:
export class Child {
@bindable onReady: () => void;
attached() {
this.onReady(); // Parent is definitely attached
}
}
Timeline:
Parent.binding()
-> Child.binding() (Too early to communicate up)
-> Child.bound()
-> Parent.bound()
-> Child.attached() (Safe to communicate up)
-> Parent.attached()
PITFALL #10: 3rd Party Library Lifecycle Mismatch
═══════════════════════════════════════════════════════════
BAD - Library not ready:
export class ChartComponent {
binding() {
// DOM not in document yet!
this.chart = new Chart(this.canvasElement); // Might fail
}
}
GOOD - Initialize in attached:
export class ChartComponent {
private chart: Chart | null = null;
attached() {
// DOM is in document and measured
this.chart = new Chart(this.canvasElement);
}
detaching() {
// Clean up before DOM removal
this.chart?.destroy();
this.chart = null;
}
}
Many libraries need:
1. Element in DOM (use attached)
2. Measured layout (use attached)
3. Cleanup before removal (use detaching)
QUICK REFERENCE: Which Hook For What?
═══════════════════════════════════════════════════════════
Task Hook
─────────────────────────────────────────────────────────
Inject services constructor
Access @bindable values binding or later
Fetch data (router) loading
Fetch data (no router) attached
Set up DOM listeners attached
Initialize 3rd party library attached
Measure DOM elements attached
Start animations attaching
Exit animations detaching
Remove DOM listeners detaching
Clean up 3rd party library detaching
Dispose long-lived subscriptions dispose
Avoid async here binding (blocks children)
Async OK here attaching, detaching, attachedMaster Aurelia's value converters for powerful data transformation. Learn formatting, localization, custom converters, performance optimization, and real-world patterns.
@aurelia/i18n is installed)How to create components that accept one or more bindable properties. You might know these as "props" if you are coming from other frameworks and libraries.
type in @bindablecoerce method@coercer decoratorset and auto-coercionfirstNamefirst-nameimport { bindable, BindingMode } from 'aurelia';
export class NameComponent {
@bindable({ mode: BindingMode.toView }) firstName = '';
@bindable({ mode: BindingMode.toView }) lastName = '';
}<p>Hello ${firstName} ${lastName}. How are you today?</p>// toView: from model to view
toView(value, ...args) { /* transform value for display */ }
// fromView: from view to model
fromView(value, ...args) { /* transform value for the model */ }<!-- String interpolation -->
<h1>${userName | capitalize}</h1>
<p>${price | currency:'USD'}</p>
<!-- Property binding -->
<input value.bind="searchTerm | normalize">
<!-- Attribute binding -->
<div class.bind="status | statusClass">import { valueConverter } from 'aurelia';
@valueConverter('capitalize')
export class CapitalizeConverter {
toView(value: string): string {
if (!value) return '';
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
}<span>${'hello world' | capitalize}</span>
<!-- Output: "Hello world" --><!-- Fixed locale -->
<span>${date | dateFormat:'en-GB'}</span>
<!-- Multiple parameters -->
<span>${price | currency:'EUR':'symbol':'1.2-2'}</span><span>${date | dateFormat:userLocale}</span>
<span>${text | truncate:maxLength:appendEllipsis}</span>export class MyComponent {
userLocale = 'fr-FR';
maxLength = 50;
appendEllipsis = true;
}<div repeat.for="item of items | sort:sortConfig">
${item.name}
</div>export class MyComponent {
sortConfig = {
property: 'name',
direction: 'asc',
caseSensitive: false
};
}<!-- Apply multiple transformations in sequence -->
<span>${userInput | sanitize | capitalize | truncate:100}</span>
<!-- With parameters -->
<span>${rawText | normalize | highlight:searchTerm | capitalize}</span><span class.bind="status | statusToClass">
${status | statusToDisplay}
</span><span>${date | dateFormat:(isDetailed ? 'long' : 'short')}</span><span>${user.profile | formatProfile:user.preferences}</span>import { valueConverter } from 'aurelia';
@valueConverter({ name: 'myConverter' })
export class MyConverter {
public readonly withContext = true;
public toView(value, caller, ...args) {
// `caller` is an object with:
// - `source`: The closest custom element view-model, if any.
// - `binding`: The binding instance (e.g., PropertyBinding, InterpolationPartBinding).
console.log('Converter called by binding:', caller.binding);
console.log('Source/Component VM:', caller.source);
// Use binding-specific state if needed, then return transformed value
return /* your transformation logic */;
}
public fromView?(value, caller, ...args) {
// For two-way binding scenarios, you can similarly access the caller properties
return /* reverse transformation logic */;
}
}<import from="./my-converter"></import>
<p>${ someValue | myConverter }</p>import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
@valueConverter('vmAware')
export class ViewModelAwareConverter {
readonly withContext = true;
toView(value: unknown, caller: ICallerContext): string {
// Direct access to the view model instance
const viewModel = caller.source as MyComponent;
// Access view model properties and methods
if (viewModel.isAdmin) {
return `Admin: ${value}`;
}
// Use view model data for transformation
return `${value} (${viewModel.userName})`;
}
}import { valueConverter, type ICallerContext } from '@aurelia/runtime-html';
interface UserComponent {
currentUser: { role: string; permissions: string[] };
isOwner(itemId: string): boolean;
}
@valueConverter('userPermission')
export class UserPermissionConverter {
readonly withContext = true;
toView(
action: string,
caller: ICallerContext,
requiredPermission?: string
): boolean {
const component = caller.source as UserComponent;
// Access view model properties
const user = component.currentUser;
if (!user) return false;
// Use view model methods
if (action === 'delete' && component.isOwner) {
return component.isOwner(requiredPermission || '');
}
// Check permissions
return user.permissions.includes(requiredPermission || action);
}
}<button if.bind="'edit' | userPermission:'edit-posts'">
Edit Post
</button>
<button if.bind="'delete' | userPermission:post.id">
Delete Post
</button>import { valueConverter } from 'aurelia';
// Simple registration
@valueConverter('capitalize')
export class CapitalizeConverter {
toView(value: string): string {
return value?.charAt(0).toUpperCase() + value?.slice(1).toLowerCase() || '';
}
}@valueConverter({
name: 'currency',
aliases: ['money', 'cash']
})
export class CurrencyConverter {
toView(value: number, locale = 'en-US', currency = 'USD'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency
}).format(value);
}
fromView(value: string): number {
// Parse currency string back to number for two-way binding
const numericValue = parseFloat(value.replace(/[^\d.-]/g, ''));
return isNaN(numericValue) ? 0 : numericValue;
}
}<span>${price | currency}</span>
<span>${price | money:'en-GB':'GBP'}</span>
<span>${price | cash}</span>export class DateFormatConverter {
static readonly $au: ValueConverterStaticAuDefinition = {
type: 'value-converter',
name: 'dateFormat',
aliases: ['df']
};
toView(value: Date, format: string = 'short'): string {
return new Intl.DateTimeFormat('en-US',
format === 'short' ? { dateStyle: 'short' } : { dateStyle: 'full' }
).format(value);
}
}import { ValueConverter, IContainer } from 'aurelia';
// Method 1: ValueConverter.define()
const DynamicConverter = ValueConverter.define('dynamic', class {
toView(value: unknown): string {
return `[Dynamic: ${value}]`;
}
});
// Method 2: Container registration
export class RuntimeConverter {
toView(value: unknown): string {
return String(value);
}
}
// Register manually in main.ts or configure function
container.register(ValueConverter.define('runtime', RuntimeConverter));// Available throughout the entire application
@valueConverter('global')
export class GlobalConverter {
toView(value: string): string {
return value.toUpperCase();
}
}import { LocalConverter } from './local-converter';
@customElement({
name: 'my-element',
template: '<span>${data | localConverter}</span>',
dependencies: [LocalConverter] // Only available in this component tree
})
export class MyElement {
data = 'hello world';
}// feature-module.ts
import { IContainer } from 'aurelia';
export function configure(container: IContainer) {
container.register(
ValueConverter.define('featureSpecific', FeatureConverter)
);
}// main.ts
import { IContainer } from 'aurelia';
export function configure(container: IContainer) {
// Production vs Development converters
if (process.env.NODE_ENV === 'development') {
container.register(DebugConverter);
}
// Feature flag based registration
if (featureFlags.enableAdvancedFormatting) {
container.register(AdvancedFormattingConverter);
}
}import { valueConverter } from 'aurelia';
@valueConverter('converterName')
export class ConverterNameValueConverter {
// Required: transform data for display
toView(value: InputType, ...args: unknown[]): OutputType {
// Transform value for display
return transformedValue;
}
// Optional: transform data from user input back to model
fromView?(value: InputType, ...args: unknown[]): OutputType {
// Transform user input back to model format
return transformedValue;
}
// Optional: signals for automatic re-evaluation
readonly signals?: string[] = ['signal-name'];
// Optional: enables binding context access
readonly withContext?: boolean = false;
}interface FormattingOptions {
locale?: string;
style?: 'decimal' | 'currency' | 'percent';
minimumFractionDigits?: number;
maximumFractionDigits?: number;
}
@valueConverter('numberFormat')
export class NumberFormatConverter {
toView(value: number | null | undefined, options: FormattingOptions = {}): string {
if (value == null || isNaN(value)) return '';
const {
locale = 'en-US',
style = 'decimal',
minimumFractionDigits,
maximumFractionDigits
} = options;
return new Intl.NumberFormat(locale, {
style,
minimumFractionDigits,
maximumFractionDigits
}).format(value);
}
fromView(value: string, options: FormattingOptions = {}): number {
const numericValue = parseFloat(value.replace(/[^\d.-]/g, ''));
return isNaN(numericValue) ? 0 : numericValue;
}
}@valueConverter('arrayJoin')
export class ArrayJoinConverter<T = unknown> {
toView(array: T[] | null | undefined, separator = ', ', formatter?: (item: T) => string): string {
if (!Array.isArray(array)) return '';
const items = formatter
? array.map(formatter)
: array.map(String);
return items.join(separator);
}
}@valueConverter('phoneNumber')
export class PhoneNumberConverter {
toView(value: string | null | undefined): string {
if (!value) return '';
// Remove all non-digits
const digits = value.replace(/\D/g, '');
// Format as (XXX) XXX-XXXX for US numbers
if (digits.length >= 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
return digits;
}
fromView(value: string): string {
// Store only digits in the model
return value.replace(/\D/g, '');
}
}<input value.two-way="user.phone | phoneNumber" placeholder="Phone number">@valueConverter('creditCard')
export class CreditCardConverter {
toView(value: string | null | undefined): string {
if (!value) return '';
const digits = value.replace(/\D/g, '');
// Format as XXXX XXXX XXXX XXXX
return digits.replace(/(.{4})/g, '$1 ').trim();
}
fromView(value: string): string {
return value.replace(/\D/g, '');
}
}@valueConverter('safeJson')
export class SafeJsonConverter {
toView(value: unknown, pretty = false): string {
try {
return JSON.stringify(value, null, pretty ? 2 : undefined);
} catch (error) {
console.warn('SafeJsonConverter: Invalid JSON value', error);
return '[Invalid JSON]';
}
}
fromView(value: string): unknown {
if (!value.trim()) return null;
try {
return JSON.parse(value);
} catch (error) {
console.warn('SafeJsonConverter: Invalid JSON string', error);
return value; // Return original string if parsing fails
}
}
}@valueConverter('expensiveTransform')
export class ExpensiveTransformConverter {
private cache = new Map<string, string>();
toView(value: string, config: TransformConfig): string {
const cacheKey = `${value}-${JSON.stringify(config)}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
const result = this.performExpensiveTransformation(value, config);
this.cache.set(cacheKey, result);
// Prevent memory leaks
if (this.cache.size > 1000) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
return result;
}
private performExpensiveTransformation(value: string, config: TransformConfig): string {
// Expensive operation here
return value;
}
}@valueConverter('nullSafe')
export class NullSafeConverter {
toView(value: unknown, fallback = ''): string {
if (value == null || value === '') return String(fallback);
return String(value);
}
}@valueConverter('debug')
export class DebugConverter {
toView(value: unknown, label = 'Debug'): unknown {
console.log(`${label}:`, value);
return value;
}
}<span>${complexData | debug:'User Data' | format}</span>import { valueConverter, ISignaler, resolve } from 'aurelia';
@valueConverter('localeDate')
export class LocaleDateConverter {
private signaler = resolve(ISignaler);
public readonly signals = ['locale-changed', 'timezone-changed'];
toView(value: string, locale?: string) {
const currentLocale = locale || this.getCurrentLocale();
return new Intl.DateTimeFormat(currentLocale, {
month: 'long',
day: 'numeric',
year: 'numeric'
}).format(new Date(value));
}
private getCurrentLocale() {
// Get current locale from your app state
return 'en-US';
}
}import { resolve, ISignaler } from 'aurelia';
export class LocaleService {
private signaler = resolve(ISignaler);
changeLocale(newLocale: string) {
// Update your locale
this.signaler.dispatchSignal('locale-changed');
}
}<!-- Automatically updates when locale changes -->
<p>${message | t}</p> <!-- Translation -->
<p>${date | df}</p> <!-- Date format -->
<p>${number | nf}</p> <!-- Number format -->
<p>${date | rt}</p> <!-- Relative time -->import { ISanitizer } from 'aurelia';
// You must register your own sanitizer implementation
export class MyHtmlSanitizer implements ISanitizer {
sanitize(input: string): string {
// Implement your sanitization logic
// You might use a library like DOMPurify here
return input; // This is just an example - implement proper sanitization!
}
}
// Register it in your main configuration
container.register(singletonRegistration(ISanitizer, MyHtmlSanitizer));<div innerHTML.bind="userContent | sanitize"></div><!-- Translation -->
<p>${'welcome.message' | t}</p>
<p>${'welcome.user' | t:{ name: userName }}</p>
<!-- Date formatting -->
<p>${date | df}</p>
<p>${date | df:{ year: 'numeric', month: 'long' }}</p>
<!-- Number formatting -->
<p>${price | nf:{ style: 'currency', currency: 'USD' }}</p>
<!-- Relative time -->
<p>${timestamp | rt}</p>import { valueConverter } from 'aurelia';
@valueConverter('date')
export class FormatDate {
toView(value: string, locale = 'en-US') {
const date = new Date(value);
if (Number.isNaN(date.valueOf())) {
return 'Invalid Date';
}
return new Intl.DateTimeFormat(locale, {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
}).format(date);
}
}<import from="./date-value-converter" /><p>${'2021-06-22T09:21:26.699Z' | date}</p>
<p>${'2021-06-22T09:21:26.699Z' | date:'en-GB'}</p>@valueConverter('fileSize')
export class FileSizeConverter {
private units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
toView(bytes: number | null | undefined, precision = 1): string {
if (bytes == null || bytes === 0) return '0 B';
if (bytes < 0) return 'Invalid size';
const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, unitIndex);
const unit = this.units[unitIndex] || 'XX';
return `${value.toFixed(precision)} ${unit}`;
}
}<span>File size: ${fileSize | fileSize:2}</span>
<!-- Output: "File size: 1.24 MB" -->@valueConverter('timeAgo')
export class TimeAgoConverter {
readonly signals = ['time-tick'];
private units = [
{ name: 'year', seconds: 31536000 },
{ name: 'month', seconds: 2592000 },
{ name: 'week', seconds: 604800 },
{ name: 'day', seconds: 86400 },
{ name: 'hour', seconds: 3600 },
{ name: 'minute', seconds: 60 },
{ name: 'second', seconds: 1 }
];
toView(date: Date | string | number | null | undefined): string {
if (!date) return '';
const now = Date.now();
const targetTime = new Date(date).getTime();
const diffInSeconds = Math.floor((now - targetTime) / 1000);
if (diffInSeconds < 0) return 'in the future';
if (diffInSeconds < 30) return 'just now';
for (const unit of this.units) {
const count = Math.floor(diffInSeconds / unit.seconds);
if (count >= 1) {
return `${count} ${unit.name}${count > 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
}@valueConverter('truncate')
export class TruncateConverter {
readonly withContext = true;
toView(
text: string | null | undefined,
caller: { binding: any, source: unknown },
maxLength = 50,
suffix = '...'
): string {
if (!text || text.length <= maxLength) return text || '';
const truncated = text.substring(0, maxLength - suffix.length) + suffix;
// Add full text as tooltip if binding target supports it
if (caller.binding?.target && 'title' in caller.binding.target) {
caller.binding.target.title = text;
}
return truncated;
}
}import { marked } from 'marked';
@valueConverter('markdown')
export class MarkdownConverter {
private renderer = new marked.Renderer();
constructor() {
// Configure marked for security
marked.setOptions({
breaks: true,
sanitize: true
});
}
toView(markdown: string | null | undefined): string {
if (!markdown) return '';
try {
return marked(markdown);
} catch (error) {
console.error('MarkdownConverter error:', error);
return markdown; // Fallback to original text
}
}
}@valueConverter('highlight')
export class HighlightConverter {
toView(
text: string | null | undefined,
searchTerm: string | null | undefined,
className = 'highlight'
): string {
if (!text || !searchTerm) return text || '';
const regex = new RegExp(`(${this.escapeRegex(searchTerm)})`, 'gi');
return text.replace(regex, `<span class="${className}">$1</span>`);
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}interface SortConfig<T = unknown> {
property?: keyof T;
direction?: 'asc' | 'desc';
compareFunction?: (a: T, b: T) => number;
caseSensitive?: boolean;
}
@valueConverter('sort')
export class SortConverter {
toView<T>(
array: T[] | null | undefined,
config: SortConfig<T> | string = {}
): T[] {
if (!Array.isArray(array)) return [];
// Handle string property shorthand
const sortConfig = typeof config === 'string'
? { property: config as keyof T }
: config;
const {
property,
direction = 'asc',
compareFunction,
caseSensitive = true
} = sortConfig;
const sorted = [...array];
if (compareFunction) {
sorted.sort(compareFunction);
} else if (property) {
sorted.sort((a, b) => {
let aVal = a[property] as any;
let bVal = b[property] as any;
// Handle string case sensitivity
if (typeof aVal === 'string' && typeof bVal === 'string' && !caseSensitive) {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return direction === 'asc' ? -1 : 1;
if (aVal > bVal) return direction === 'asc' ? 1 : -1;
return 0;
});
}
return direction === 'desc' ? sorted.reverse() : sorted;
}
}@valueConverter('color')
export class ColorConverter {
toView(
color: string | null | undefined,
format: 'hex' | 'rgb' | 'hsl' = 'hex'
): string {
if (!color) return '';
try {
const rgb = this.parseColor(color);
switch (format) {
case 'rgb':
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
case 'hsl':
return this.rgbToHsl(rgb);
case 'hex':
default:
return this.rgbToHex(rgb);
}
} catch (error) {
console.warn('ColorConverter: Invalid color format', color);
return color;
}
}
private parseColor(color: string): { r: number; g: number; b: number } {
// Implementation for parsing various color formats
// This is simplified - you'd want a more robust color parsing library
if (color.startsWith('#')) {
const hex = color.slice(1);
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
throw new Error(`Unsupported color format: ${color}`);
}
private rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string {
return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
}
private rgbToHsl({ r, g, b }: { r: number; g: number; b: number }): string {
// HSL conversion logic
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
}
}@valueConverter('expensiveFormat')
export class ExpensiveFormatConverter {
private cache = new Map<string, string>();
private maxCacheSize = 1000;
toView(value: string, config: ComplexConfig): string {
const cacheKey = this.createCacheKey(value, config);
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
const result = this.performExpensiveTransformation(value, config);
// Implement LRU cache behavior
if (this.cache.size >= this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(cacheKey, result);
return result;
}
private createCacheKey(value: string, config: ComplexConfig): string {
return `${value}:${JSON.stringify(config)}`;
}
}@valueConverter('lazyTransform')
export class LazyTransformConverter {
private transformPromises = new WeakMap<object, Promise<string>>();
toView(data: ComplexData): string | Promise<string> {
if (this.transformPromises.has(data)) {
return this.transformPromises.get(data)!;
}
const promise = this.performAsyncTransformation(data);
this.transformPromises.set(data, promise);
return promise;
}
private async performAsyncTransformation(data: ComplexData): Promise<string> {
// Expensive async operation
return 'transformed result';
}
}@valueConverter('memoryAware')
export class MemoryAwareConverter {
private observers = new Set<() => void>();
private cache = new Map();
toView(value: string): string {
// Clean up old observers
this.cleanup();
// Your transformation logic
return this.transform(value);
}
private cleanup(): void {
// Dispose observers and clear caches periodically
if (this.observers.size > 100) {
this.observers.forEach(cleanup => cleanup());
this.observers.clear();
this.cache.clear();
}
}
}@valueConverter('profiled')
export class ProfiledConverter {
private performanceMetrics = new Map<string, number>();
toView(value: string, operation: string): string {
const start = performance.now();
const result = this.performTransformation(value, operation);
const duration = performance.now() - start;
// Track performance metrics
const key = `${operation}-${typeof value}`;
const existing = this.performanceMetrics.get(key) || 0;
this.performanceMetrics.set(key, (existing + duration) / 2);
return result;
}
getPerformanceReport(): Record<string, number> {
return Object.fromEntries(this.performanceMetrics);
}
}// ✅ Good - focused on one transformation
@valueConverter('capitalize')
export class CapitalizeConverter {
toView(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
}
// ❌ Bad - doing too many things
@valueConverter('formatEverything')
export class FormatEverythingConverter {
toView(value: unknown, type: string): string {
// This converter tries to handle too many different cases
}
}// ✅ Good - no side effects
@valueConverter('multiply')
export class MultiplyConverter {
toView(value: number, factor: number): number {
return value * factor;
}
}
// ❌ Bad - side effects
@valueConverter('logAndMultiply')
export class LogAndMultiplyConverter {
toView(value: number, factor: number): number {
console.log('Processing:', value); // Side effect
this.updateGlobalCounter(); // Side effect
return value * factor;
}
}interface DateFormatOptions {
locale?: string;
dateStyle?: 'full' | 'long' | 'medium' | 'short';
timeStyle?: 'full' | 'long' | 'medium' | 'short';
}
@valueConverter('dateFormat')
export class DateFormatConverter {
toView(
date: Date | string | number | null | undefined,
options: DateFormatOptions = {}
): string {
if (!date) return '';
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) return 'Invalid Date';
const { locale = 'en-US', ...formatOptions } = options;
return new Intl.DateTimeFormat(locale, formatOptions).format(dateObj);
}
}interface Filterable {
[key: string]: unknown;
}
@valueConverter('filter')
export class FilterConverter {
toView<T extends Filterable>(
items: T[] | null | undefined,
predicate: (item: T) => boolean
): T[] {
if (!Array.isArray(items)) return [];
return items.filter(predicate);
}
}@valueConverter('resilient')
export class ResilientConverter {
toView(value: unknown, options: ConversionOptions = {}): string {
try {
return this.performConversion(value, options);
} catch (error) {
// Log for debugging but don't break the UI
console.warn(`ResilientConverter failed for value:`, value, error);
// Return safe fallback
return options.fallback || String(value) || '';
}
}
private performConversion(value: unknown, options: ConversionOptions): string {
// Potentially throwing conversion logic
throw new Error('Conversion failed');
}
}describe('CurrencyConverter', () => {
let converter: CurrencyConverter;
beforeEach(() => {
converter = new CurrencyConverter();
});
it('should format USD currency correctly', () => {
const result = converter.toView(1234.56, { locale: 'en-US', currency: 'USD' });
expect(result).toBe('$1,234.56');
});
it('should handle null values gracefully', () => {
const result = converter.toView(null);
expect(result).toBe('');
});
it('should parse formatted currency back to number', () => {
const result = converter.fromView('$1,234.56');
expect(result).toBe(1234.56);
});
});<import from="./my-converter"></import>@valueConverter('myConverter') // Must match template usage
export class MyConverter { }// In main.ts
import { MyConverter } from './my-converter';
Aurelia.register(MyConverter).app(MyApp).start();private cache = new Map();
toView(value: string): string {
if (this.cache.has(value)) return this.cache.get(value);
// ... expensive operation
}readonly signals = ['data-changed'];
// Update only when signal is dispatched<!-- ❌ Bad - converter called for every item -->
<div repeat.for="item of items">
${expensiveData | expensiveConverter}
</div>
<!-- ✅ Good - converter called once -->
<div repeat.for="item of items">
${item.name}
</div>
<div>${expensiveData | expensiveConverter}</div>readonly withContext = true; // Required propertytoView(value: unknown, caller: ICallerContext, ...args: unknown[]): unknown {
// caller is always second parameter when withContext = true
}readonly signals = ['my-signal']; // Array of signal namesimport { resolve } from '@aurelia/kernel';
import { ISignaler } from '@aurelia/runtime-html';
private signaler = resolve(ISignaler);
updateData(): void {
// Update data first
this.signaler.dispatchSignal('my-signal');
}<!-- Translation with parameters -->
<span>${'welcome.message' | t:{ name: userName }}</span>
<!-- Date formatting -->
<span>${createdDate | df:{ dateStyle: 'full', timeStyle: 'short' }}</span>
<!-- Currency formatting -->
<span>${price | nf:{ style: 'currency', currency: 'EUR' }}</span>
<!-- Relative time -->
<span>Posted ${postDate | rt}</span>import { bindable } from 'aurelia';
export class LoaderComponent {
@bindable loading = false;
}<loader loading.bind="true"></loader><loader loading.bind="loadingVal"></loader><loader loading="true"></loader>import { bindable } from 'aurelia';
export class NameComponent {
@bindable firstName = '';
@bindable lastName = '';
bound() {
this.firstNameChanged(this.firstName, undefined);
}
firstNameChanged(newVal, oldVal) {
console.log('Value changed');
}
}export class NameComponent {
@bindable firstName = '';
@bindable lastName = '';
propertyChanged(key, newVal, oldVal) {
if (key === 'firstName') {
// Handle firstName change
} else if (key === 'lastName') {
// Handle lastName change
}
}
}propertiesChanged({ firstName, lastName }) {
if (firstName && lastName) {
// both firstName and lastName were changed at the same time
// apply first update strategy
const { newValue: newFirstName, oldValue: oldFirstName } = firstName;
const { newValue: newLastName, oldValue: oldLastName } = lastName;
} else if (firstName) {
// only firstName was changed - apply second update strategy
// ...
} else {
// only lastName was changed - apply third update strategy
// ...
}
}class MyComponent {
@bindable prop = 0
propChanged() { console.log('prop changed'); }
propertyChanged(name) { console.log(`property "${name}" changed`) }
propertiesChanged(changes) {
console.log('changes are:', changes)
}
}myComponent.prop = 1;
console.log('after assign');propChanged
property "prop" changed
after assign
changes are, { prop: { newValue: 1, oldValue: 0 } }import { bindable, BindingMode } from 'aurelia';
export class NameComponent {
@bindable({ mode: BindingMode.twoWay}) firstName = '';
@bindable({ callback: 'lnameChanged' }) lastName = '';
lnameChanged(val) {}
}import { bindable } from 'aurelia';
export class UserCard {
@bindable({ attribute: 'user-id' }) id = '';
}<user-card user-id="123"></user-card>import { bindable, customAttribute } from 'aurelia';
@customAttribute({ name: 'tooltip', defaultProperty: 'message' })
export class TooltipCustomAttribute {
@bindable message = '';
@bindable position = 'top';
}<!-- The value "Hello" goes to the default property (message) -->
<div tooltip="Hello"></div>
<!-- Equivalent explicit syntax -->
<div tooltip="message: Hello"></div>
<!-- Using multiple bindables -->
<div tooltip="message: Hello; position: bottom"></div>import { bindable, BindingMode } from 'aurelia';
export class Loader {
@bindable({ mode: BindingMode.toView }) loading = false;
}import { bindable, BindingMode } from 'aurelia';
export class Loader {
@bindable({ mode: BindingMode.twoWay }) loading = false;
}import { bindable, BindingMode } from 'aurelia';
export class Loader {
@bindable({ mode: BindingMode.oneTime }) config = {};
}import { bindable, BindingMode } from 'aurelia';
export class Loader {
@bindable({ mode: BindingMode.fromView }) userInput = '';
}<input type="text" value.two-way="myVal">@bindable({
set: value => someFunction(value), /* HERE */
// Or set: value => value,
mode: /* ... */
})<!-- Enable -->
<my-carousel navigator.bind="true">
<my-carousel navigator="true">
<my-carousel navigator=true>
<my-carousel navigator>
<!-- Disable -->
<my-carousel navigator.bind="false">
<my-carousel navigator="false">
<my-carousel navigator=false>
<my-carousel>@bindable({ set: /* ? */, mode: BindingMode.toView }) public navigator: boolean = false;export function truthyDetector(value: unknown) {
return value === '' || value === true || value === "true";
}@bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: boolean = false;@bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: boolean = false;import { BindingMode, bindable, customElement, ICustomElementViewModel } from 'aurelia';
@customElement({ name: 'card-nav', template })
export class CardNav implements ICustomElementViewModel {
@bindable routes: RouteLink[] = [];
@bindable({ mode: BindingMode.fromView }) active?: string;
bound() {
this.setActive();
}
setActive() {
this.active = this.routes.find((y) => y.isActive)?.path;
}
handleClick(route: RouteLink) {
this.routes.forEach((x) => (x.isActive = x === route));
this.setActive();
}
}import { BindingMode, bindable, customElement, ICustomElementViewModel } from 'aurelia';
@customElement({ name: 'card-nav', template })
export class CardNav implements ICustomElementViewModel {
@bindable routes: RouteLink[] = [];
@bindable({ mode: BindingMode.fromView }) get active() {
return this.routes.find((y) => y.isActive)?.path;
}
handleClick(route: RouteLink) {
this.routes.forEach((x) => (x.isActive = x === route));
}
}@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable public num: number;
}@customElement({ name:'my-app', template: '<my-el num="42"></my-el>' })
export class MyApp { }new Aurelia()
.register(
StandardConfiguration
.customize((config) => {
config.coercingOptions.enableCoercion = true;
// config.coercingOptions.coerceNullish = true;
}),
...
);@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable({ type: Number }) num : number;
}export class Person {
public constructor(
public readonly name: string,
public readonly age: number,
) { }
public static coerce(value: unknown): Person {
if (value instanceof Person) return value;
if (typeof value === 'string') {
try {
const json = JSON.parse(value) as Person;
return new this(json.name, json.age);
} catch {
return new this(value, null!);
}
}
if (typeof value === 'number') {
return new this(null!, value);
}
if (typeof value === 'object' && value != null) {
return new this((value as any).name, (value as any).age);
}
return new this(null!, null!);
}
}import { Person } from './person.ts';
@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable public person: Person;
}@customElement({ name:'my-app', template: '<my-el person="john"></my-el>' })
export class MyApp { }import { coercer } from '@aurelia/runtime-html';
export class Person {
public constructor(
public readonly name: string,
public readonly age: number,
) { }
@coercer
public static createFrom(value: unknown): Person {
if (value instanceof Person) return value;
if (typeof value === 'string') {
try {
const json = JSON.parse(value) as Person;
return new this(json.name, json.age);
} catch {
return new this(value, null!);
}
}
if (typeof value === 'number') {
return new this(null!, value);
}
if (typeof value === 'object' && value != null) {
return new this((value as any).name, (value as any).age);
}
return new this(null!, null!);
}
}import { Person } from './person.ts';
@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable public person: Person;
}@customElement({ name:'my-app', template: '<my-el person="john"></my-el>' })
export class MyApp { }@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable({ nullable: false }) public num: number;
}@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable public num: number | string;
}@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable({type: String}) public num: number | string;
}@customElement({ name:'my-el', template: 'not important' })
export class MyEl {
@bindable({set(v: unknown) {... return coercedV;}}) public num: number | string;
}export class NameTag {
@bindable first
@bindable last
}<b>${first.toUpperCase()}</b> ${last}<name-tag ...$bindables="{ first: 'John', last: 'Doe' }"></name-tag><b>JOHN</b> Doe<name-tag $bindables.spread="customer1">
<name-tag $bindables.spread="customer.details">
<name-tag $bindables.spread="customer[this_that]">
<name-tag $bindables.spread="customer1 | mapDetails">
<name-tag $bindables.spread="customer.details | simplify">
<name-tag $bindables.spread="customer[this_that] | addDetails"><name-tag ...customer1>
<name-tag ...customer.details>
<name-tag ...customer[this_that]>
or if you need space in the expression:
<name-tag ...$bindables="customer1 | mapDetails">
<name-tag ...$bindables="customer.details | simplify">
<name-tag ...$bindables="customer[this_that] | addDetails"><name-tag id="1" first="John" ...$bindables="{ first: 'Jane' }">
<name-tag id="2" ...$bindables="{ first: 'Jane' }" first="John"><let item.bind="{ first: 'John' }">
<name-tag ...item></name-tag>
<button click.trigger="item.last = 'Doe'">Change last name</button><b>JOHN</b><let item.bind="{ first: 'John' }">
<name-tag ...item></name-tag>
<button click.trigger="item = { first: item.name, last: 'Doe' }">Change last name</button>export class FormInput {
@bindable label
@bindable value
}<label>${label}
<input value.bind="value">
</label>export class FormInput {
@bindable label
@bindable value
@bindable type
@bindable tooltip
@bindable arias
@bindable etc
}<form-input
label.bind="label"
value.bind="message"
tooltip.bind="Did you know Aurelia syntax comes from an idea of an Angular community member? We greatly appreciate Angular and its community for this."
validation.bind="..."><label>${label}
<input value.bind tooltip.bind validation.bind min.bind max.bind>
</label><label>${label}
<input ...$attrs>
</label>@customElement({
...,
capture: true
})import { capture } from 'aurelia';
@capture
export class MyCustomElement {
...
}
// either form is valid
@capture()
export class MyCustomElement {
...
}<input ...$attrs><input value.bind="..." ...$attrs> spread wins
<input ...$attrs value.bind="..."> explicit wins<capture>
<input ...$attrs>@customElement({
capture: attr => attr !== 'class' // Captures all attributes except 'class'
})export class FormInput {
@bindable label
}<form-input
if.bind="needsComment"
label.bind="label"
value.bind="extraComment"
class="form-control"
style="background: var(--theme-purple)"
tooltip="Hello, ${tooltip}">app.html
<input-field value.bind="message">
input-field.html
<my-input ...$attrs>// Performance comparison
export class ComponentWrapper {
// Good for small, known property sets
@bindable name: string;
@bindable age: number;
// Better for dynamic or large property sets
@bindable userData: UserData;
}// More efficient - limited observation
const userData = { name: 'John', age: 30 };
<user-profile ...userData>
// Less efficient - observes all properties
const userData = { name: 'John', age: 30, metadata: {...}, history: [...] };
<user-profile ...userData><!-- Object transformation -->
<user-card ...user.profile>
<user-card ...user.permissions.admin>
<user-card ...getCurrentUser().settings>
<!-- With value converters -->
<user-card ...$bindables="user | formatUser">
<user-card ...$bindables="user.profile | selectFields:['name', 'email']">
<!-- Dynamic property selection -->
<user-card ...$bindables="user | pick:allowedFields"><!-- Spread with binding behaviors -->
<input-field ...$bindables="formData | filterEmpty & debounce:500">
<!-- Spread with conditional logic -->
<user-form ...$bindables="user & if:isEditMode">
<!-- Mixed binding patterns -->
<input-field ...user
...$attrs
id.bind="fieldId"
class="form-control"
validation.bind="userValidation">// Null-safe spreading
@customElement({ name: 'safe-component' })
export class SafeComponent {
@bindable data: any;
get safeData() {
return this.data || {};
}
}<!-- Template with null safety -->
<safe-component ...safeData>
<!-- or -->
<safe-component ...$bindables="data || {}">// Good: Clear, focused spreading
export class FormField {
@bindable label: string;
@bindable required: boolean;
// Spread for input-specific attributes
@customElement({ capture: true })
}<!-- Template shows clear intent -->
<label>${label}
<input ...$attrs class="form-control">
</label>// Define interfaces for spread objects
interface UserProfile {
name: string;
email: string;
avatar?: string;
}
export class UserCard {
@bindable profile: UserProfile;
}<!-- Type-safe spreading -->
<user-card ...user.profile>// Pattern 1: Wrapper Components
@customElement({ name: 'styled-input', capture: true })
export class StyledInput {
@bindable label: string;
@bindable theme: string;
}<div class="input-group ${theme}">
<label>${label}</label>
<input ...$attrs>
</div>// Pattern 2: Configuration Objects
export class DataTable {
@bindable columns: Column[];
@bindable options: TableOptions;
}<data-table columns.bind="userColumns" ...tableConfig>// Anti-pattern 1: Spreading entire application state
export class BadComponent {
@bindable appState: ApplicationState; // Too broad
}<!-- Anti-pattern 2: Excessive nesting -->
<wrapper-1 ...data>
<wrapper-2 ...data>
<wrapper-3 ...data>
<actual-component ...data>
</wrapper-3>
</wrapper-2>
</wrapper-1><!-- Problem: HTML converts to lowercase -->
<component ...firstName> <!-- becomes ...firstname -->
<!-- Solution: Use explicit binding -->
<component ...$bindables="{ firstName: user.firstName }">// Problem: Adding properties after initial binding
this.userData.newProperty = 'value'; // Not observed
// Solution: Replace the entire object
this.userData = { ...this.userData, newProperty: 'value' };<!-- Problem: Spread overrides explicit binding -->
<input value.two-way="data.value" ...formConfig>
<!-- Solution: Put explicit bindings after spread -->
<input ...formConfig value.two-way="data.value">// Enable detailed binding information in development
@customElement({
name: 'debug-component',
capture: (attr: string) => {
if (__DEV__) {
console.log('Capturing attribute:', attr);
}
return !attr.startsWith('debug-');
}
})┌──────────────────────────────────────────────────────────┐
│ User navigates to: /products/42/reviews │
└──────────────────┬───────────────────────────────────────┘
↓
┌──────────────────────┐
│ 1. Parse URL │
│ Path: /products/42/ │
│ reviews │
│ Fragment: #section2 │
│ Query: ?sort=date │
└──────────┬───────────┘
↓
┌──────────────────────┐
│ 2. Match Routes │
│ - Check path pattern │
│ - Extract params │
│ - Apply constraints │
└──────────┬───────────┘
↓
┌──────────────────────┐
│ 3. Build Route Tree │
│ Root │
│ └─ Products (:id) │
│ └─ Reviews │
└──────────┬───────────┘
↓
┌──────────────────────┐
│ 4. Execute Hooks │
│ - canLoad (guard) │
│ - loading (data) │
│ - canUnload (prev) │
└──────────┬───────────┘
↓
┌──────────────────────┐
│ 5. Render Components │
│ - Swap viewports │
│ - loaded hooks │
│ - Update title │
└──────────────────────┘
Route Configuration Match Example:
routes: [
{
path: 'products/:id', ✓ Matches /products/42
component: ProductDetail,
routes: [
{ path: 'reviews', ... } ✓ Matches /reviews
]
},
{
path: 'products/:id{{^\\d+$}}', ✓ Only if :id is numeric
component: ProductDetail
}
]┌─────────────────────────────────────────────────────────────┐
│ NAVIGATION METHODS │
└─────────────────────────────────────────────────────────────┘
METHOD 1: href attribute (Declarative)
─────────────────────────────────────────
<a href="products/42"> ┌──────────────┐
─────────────────────────────>│ href handler │
└──────┬───────┘
↓
┌──────────────────┐
│ Parse URL string │
└──────────┬───────┘
↓
Navigate to URL
Context: Current route context by default
Use ../ to navigate to parent context
METHOD 2: load attribute (Structured)
──────────────────────────────────────────
<a load="route: products; ┌──────────────┐
params.bind: {id: 42}">───>│ load handler │
└──────┬───────┘
↓
┌──────────────────────┐
│ Build instruction │
│ from structured data │
└──────────┬───────────┘
↓
Navigate to route
Context: Current by default, can bind custom context
Active: Supports .active bindable for styling
METHOD 3: IRouter.load() (Programmatic)
────────────────────────────────────────────
router.load('products/42', { ┌──────────────┐
queryParams: { ... }, │ IRouter.load │
context: this └──────┬───────┘
}); ↓
┌──────────────────────┐
│ Full JavaScript API │
│ - Error handling │
│ - Async/await │
│ - Options object │
└──────────┬───────────┘
↓
Navigate to route
Context: Root by default (different from href/load!)
Returns: Promise<boolean> for success/failure
ALL METHODS CONVERGE
────────────────────────────────────────────
↓
┌────────────────────┐
│ Router Core Engine │
└──────────┬─────────┘
↓
┌────────────────────┐
│ Route Matching │
│ Hook Execution │
│ Component Loading │
└────────────────────┘┌────────────────────────────────────────────────────────┐
│ Navigation: /page-a → /page-b │
└────────────────────────────────────────────────────────┘
PHASE 1: CAN UNLOAD (Current Component)
════════════════════════════════════════
ComponentA (current)
↓
┌─────────────────────────────────┐
│ 1. canUnload() │ → Return false to cancel navigation
│ - Check unsaved changes │ Return true to allow
│ - User confirmation │
└─────────────────┬───────────────┘
↓
[Navigation Cancelled?] ─── No ──→ Continue
│
Yes
↓
Stay on page A
PHASE 2: CAN LOAD (Next Component)
══════════════════════════════════════
ComponentB (next)
↓
┌─────────────────────────────────┐
│ 2. Router hooks: canLoad() │ → Return false to block
│ - Authentication checks │ Return NavigationInstruction to redirect
│ - Authorization │ Return true to allow
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 3. Component: canLoad() │ → Component-level validation
│ - Parameter validation │
│ - Conditional logic │
└─────────────────┬───────────────┘
↓
[Navigation Allowed?] ─── No ──→ Show fallback or redirect
│
Yes
↓
Continue to load
PHASE 3: UNLOADING (Current Component)
═══════════════════════════════════════
ComponentA (current)
↓
┌─────────────────────────────────┐
│ 4. Router hooks: unloading() │
│ - Global cleanup │
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 5. Component: unloading() │
│ - Save drafts │
│ - Cleanup subscriptions │
│ - Log analytics │
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 6. Component detached │ ← Standard Aurelia lifecycle
│ - DOM removal │
└─────────────────────────────────┘
PHASE 4: LOADING (Next Component)
══════════════════════════════════════
ComponentB (next)
↓
┌─────────────────────────────────┐
│ 7. Router hooks: loading() │
│ - Shared data loading │
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 8. Component: loading() │
│ - Fetch component data │
│ - Initialize state │
│ - Show loading UI │
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 9. Component attached │ ← Standard Aurelia lifecycle
│ - DOM insertion │
└─────────────────┬───────────────┘
↓
Swap viewport content
(ComponentA → ComponentB)
↓
┌─────────────────────────────────┐
│ 10. Component: loaded() │
│ - Post-render effects │
│ - Scroll to top │
│ - Track page view │
└─────────────────┬───────────────┘
↓
┌─────────────────────────────────┐
│ 11. Update browser history │
│ Update document title │
└─────────────────────────────────┘
↓
Navigation Complete
TIMING DIAGRAM (with async operations)
═══════════════════════════════════════════
Time ComponentA ComponentB
──── ────────── ──────────
0ms canUnload() ────────┐
│
100ms └─> [approved]
canLoad() ──────┐
│
200ms └─> [approved]
unloading() ───────┐
│
250ms └─> [cleanup done]
loading() ──────┐
│ ← async data fetch
400ms └─> [data loaded]
[detached]
[attached]
loaded() ───────┐
│
410ms └─> [done]
████████ (visible) ░░░░░░░░ (hidden)
░░░░░░░░ (hidden) ████████ (visible)┌─────────────────────────────────────────────────────────────┐
│ COMPONENT HOOKS (Local) │
├─────────────────────────────────────────────────────────────┤
│ │
│ export class ProductDetail implements IRouteViewModel { │
│ canLoad(params: Params): boolean { │
│ // 'this' refers to component instance │
│ return this.validateProduct(params.id); │
│ } │
│ } │
│ │
│ ✓ Use for component-specific logic │
│ ✓ Direct access to component state via 'this' │
│ ✓ Runs only for this component │
│ ✗ Cannot share logic across components │
└─────────────────────────────────────────────────────────────┘
↓↑
┌─────────────────────────────────────────────────────────────┐
│ ROUTER HOOKS (Shared/Global) │
├─────────────────────────────────────────────────────────────┤
│ │
│ @lifecycleHooks() │
│ export class AuthHook { │
│ canLoad( │
│ viewModel: IRouteViewModel, ← component instance │
│ params: Params, │
│ next: RouteNode │
│ ): boolean { │
│ // 'this' is the hook instance, not the component │
│ return this.authService.isAuthenticated(); │
│ } │
│ } │
│ │
│ // Register globally │
│ Aurelia.register(AuthHook); │
│ │
│ ✓ Share logic across all components │
│ ✓ Centralized cross-cutting concerns │
│ ✓ Access component via viewModel parameter │
│ ✗ Extra indirection to access component state │
└─────────────────────────────────────────────────────────────┘
EXECUTION ORDER (both registered)
══════════════════════════════════════════════════════════════
Navigation triggered
↓
┌─────────────────────┐
│ 1. Router Hooks │ ← Runs first (global checks)
│ canLoad() │
└──────────┬──────────┘
↓
[return false?] ─── Yes ──→ Navigation blocked
│
No
↓
┌─────────────────────┐
│ 2. Component Hook │ ← Runs second (local checks)
│ canLoad() │
└──────────┬──────────┘
↓
[return false?] ─── Yes ──→ Navigation blocked
│
No
↓
Navigation continues
COMMON PATTERNS
═══════════════════════════════════════════════════════════
Pattern 1: Authentication (Router Hook)
────────────────────────────────────────
@lifecycleHooks()
class AuthHook {
canLoad(...) {
if (!isLoggedIn) return 'login';
return true;
}
}
→ Applies to all routes
→ Centralized auth logic
Pattern 2: Data Loading (Component Hook)
─────────────────────────────────────────
class ProductDetail implements IRouteViewModel {
async loading(params: Params) {
this.product = await fetchProduct(params.id);
}
}
→ Component-specific data
→ Direct state access
Pattern 3: Mixed Approach (Both)
─────────────────────────────────────────
@lifecycleHooks()
class PermissionHook {
canLoad(vm, params, next) {
const requiredPermission = next.data?.permission;
return this.hasPermission(requiredPermission);
}
}
class AdminPanel implements IRouteViewModel {
canLoad(params) {
// Additional component-specific checks
return this.validateContext(params);
}
}
→ Global permission check first
→ Then component-specific validationSIMPLE (SINGLE VIEWPORT)
════════════════════════════════════
<my-app>
<nav>...</nav>
<au-viewport></au-viewport> ← Single viewport
</my-app>
Route: /products
↓
┌────────────────┐
│ <my-app> │
│ <nav> │
│ ┌──────────┐ │
│ │ Products │ │ ← Loaded into viewport
│ └──────────┘ │
│ </my-app> │
└────────────────┘
HIERARCHICAL (NESTED VIEWPORTS)
═══════════════════════════════════════════════
<my-app>
<au-viewport></au-viewport> ← Root viewport
↓
<products-page>
<au-viewport></au-viewport> ← Child viewport
↓
<product-detail>
</product-detail>
</products-page>
</my-app>
Route: /products/42/reviews
↓
┌─────────────────────────────────────┐
│ Root Component (my-app) │
│ ┌─────────────────────────────────┐ │
│ │ Products (viewport: default) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Product 42 (viewport: deflt)│ │ │
│ │ │ ┌─────────────────────────┐ │ │ │
│ │ │ │ Reviews (viewport: def) │ │ │ │
│ │ │ └─────────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
Route Tree:
Root
└─ products
└─ 42 (product-detail)
└─ reviews
SIBLING VIEWPORTS (MULTIPLE VIEWPORTS)
═══════════════════════════════════════════════
<my-app>
<div class="layout">
<au-viewport name="left"></au-viewport>
<au-viewport name="right"></au-viewport>
</div>
</my-app>
Route: products@left+details/42@right
↓
┌───────────────────────────────────────┐
│ Root Component │
│ ┌───────────────┬─────────────────┐ │
│ │ Products │ Product Details │ │
│ │ (left) │ (right) │ │
│ │ │ ID: 42 │ │
│ │ - Item 1 │ │ │
│ │ - Item 2 │ Description... │ │
│ │ - Item 3 │ │ │
│ └───────────────┴─────────────────┘ │
└───────────────────────────────────────┘
Route Configuration:
routes: [
{ path: 'products', component: ProductList },
{ path: 'details/:id', component: ProductDetail }
]
Navigation:
<a href="products@left+details/42@right">Load both</a>
router.load([
{ component: ProductList, viewport: 'left' },
{ component: ProductDetail, params: { id: 42 }, viewport: 'right' }
]);
COMPLEX (NESTED + SIBLING)
═══════════════════════════════════════════════
<my-app>
<au-viewport></au-viewport> ← Root
↓
<dashboard>
<au-viewport name="main"></au-viewport>
<au-viewport name="sidebar"></au-viewport>
↓ ↓
<content> <sidebar-content>
<au-viewport></au-viewport> ← Nested in main
</content>
</dashboard>
</my-app>
Route: /dashboard/content@main+sidebar@sidebar/nested
↓
┌──────────────────────────────────────────┐
│ Root (my-app) │
│ ┌──────────────────────────────────────┐ │
│ │ Dashboard │ │
│ │ ┌─────────────────┬────────────────┐ │ │
│ │ │ Main │ Sidebar │ │ │
│ │ │ ┌─────────────┐ │ │ │ │
│ │ │ │ Nested Comp │ │ Sidebar Content│ │ │
│ │ │ └─────────────┘ │ │ │ │
│ │ └─────────────────┴────────────────┘ │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘STRATEGY: 'push' (default)
══════════════════════════════════════════
User Journey:
/home → /about → /contact
Browser History Stack:
┌─────────────┐
│ /contact │ ← Current (length: 3)
├─────────────┤
│ /about │ [Back button goes here]
├─────────────┤
│ /home │
└─────────────┘
Code:
router.load('contact', { historyStrategy: 'push' });
✓ Each navigation adds new entry
✓ Back button works as expected
✓ Forward button available after going back
✗ History grows unbounded
STRATEGY: 'replace'
══════════════════════════════════════════
User Journey:
/home → /about → /contact (replace)
Browser History Stack:
┌─────────────┐
│ /contact │ ← Current (length: 2)
├─────────────┤
│ /home │ [Back button goes here]
└─────────────┘
↑
/about was replaced by /contact
Code:
router.load('contact', { historyStrategy: 'replace' });
✓ No history pollution
✓ Good for redirects/corrections
✓ Prevents "back" to intermediate states
✗ Can't navigate back to replaced pages
STRATEGY: 'none'
══════════════════════════════════════════
User Journey:
/home → /about → /contact (none)
Browser History Stack:
┌─────────────┐
│ /home │ ← Current (length: 1)
└─────────────┘
URL bar shows: /contact
But history still has: /home
Code:
router.load('contact', { historyStrategy: 'none' });
✓ No history interaction at all
✓ Good for modal-style navigation
✗ Back button goes to previous app page, not /about
✗ URL and history out of sync
COMPARISON
══════════════════════════════════════════════════════════
Use Case | Strategy
─────────────────────────────────────────────────────────
Normal navigation | 'push'
Login redirect | 'replace'
Fixing invalid route | 'replace'
Multi-step form (same logical page)| 'replace'
Modal / overlay content | 'none'
Wizard steps (want back to work) | 'push'
Correcting user typos in URL | 'replace'
REAL-WORLD EXAMPLE: Login Flow
═══════════════════════════════════
// User tries to access protected route
canLoad() {
if (!isLoggedIn) {
// Redirect to login WITH replace
// So after login, "back" doesn't go to login page
router.load('login', { historyStrategy: 'replace' });
return false;
}
}
// After successful login
login() {
authenticate();
// Navigate to dashboard WITH replace
// So "back" from dashboard doesn't go to login
router.load('dashboard', { historyStrategy: 'replace' });
}
History progression:
1. User at /home
2. Tries /admin → redirected to /login (replace)
History: [/home, /login]
3. After login → /admin (replace)
History: [/home, /admin]
4. Back button → goes to /home (skips /login)
REAL-WORLD EXAMPLE: Wizard
═══════════════════════════════════
// Multi-step form
wizard.nextStep() {
currentStep++;
// Use push so back button works
router.load(`wizard/step${currentStep}`, {
historyStrategy: 'push'
});
}
History: /wizard/step1 → /wizard/step2 → /wizard/step3
Back button goes through steps correctly
REAL-WORLD EXAMPLE: Search Filters
══════════════════════════════════════
// User adjusts filters
applyFilters() {
// Use replace to update URL without history spam
router.load('search', {
queryParams: { ...filters },
historyStrategy: 'replace'
});
}
Without replace:
/search → /search?cat=A → /search?cat=A&sort=price
→ /search?cat=A&sort=price&page=2
→ /search?cat=A&sort=price&page=3
[User hits back 4 times to go back!]
With replace:
/search → /search?cat=A&sort=price&page=3
[User hits back once to go back!]Scenario: Navigate from /users/1 to /users/2
(Same component, different parameter)
TRANSITION PLAN: 'replace' (default)
════════════════════════════════════════
/users/1 (ComponentA, id=1)
↓
router.load('/users/2')
↓
┌──────────────────────────────┐
│ 1. Unload current instance │
│ - unloading() called │
│ - detaching() called │
│ - Component destroyed │
└────────────┬─────────────────┘
↓
┌──────────────────────────────┐
│ 2. Create new instance │
│ - New component instance │
│ - canLoad() called │
│ - loading() called │
│ - attached() called │
│ - loaded() called │
└────────────┬─────────────────┘
↓
/users/2 (ComponentA, id=2) ← Different instance
Timeline:
ComponentA(id=1) ComponentA(id=2)
unloading()
detaching()
[destroyed]
canLoad()
loading()
attached()
loaded()
✓ Clean slate, no stale state
✓ Simple mental model
✗ Slower (full recreation)
✗ Loses component state
✗ Re-runs constructor, bound, etc.
TRANSITION PLAN: 'invoke-lifecycles'
════════════════════════════════════════
/users/1 (ComponentA, id=1)
↓
router.load('/users/2')
↓
┌──────────────────────────────┐
│ 1. Keep existing instance │
│ - Same component object │
│ - No destruction │
└────────────┬─────────────────┘
↓
┌──────────────────────────────┐
│ 2. Re-invoke hooks │
│ - canLoad() called │
│ - loading() called │
│ - loaded() called │
│ (NO attach/detach) │
└────────────┬─────────────────┘
↓
/users/2 (ComponentA, id=2) ← Same instance!
Timeline:
ComponentA(id=1)
canLoad(id=2)
loading(id=2)
loaded(id=2)
ComponentA(id=2)
✓ Faster (reuses instance)
✓ Can preserve component state
✓ Smoother transitions/animations
✗ Must handle state updates correctly
✗ Potential for stale data bugs
COMPARISON
══════════════════════════════════════════════════════════
Aspect | replace | invoke-lifecycles
────────────────────────────────────────────────────────────────
Instance | New | Reused
Speed | Slower | Faster
State | Fresh | Preserved*
Lifecycle hooks | All | Subset
DOM | Removed/readded | Stays
Use for | Default behavior | Param-only changes
* Preserved state can be a pro or con depending on use case
CONFIGURATION
═══════════════════════════════════════════════════════════
Global configuration:
@route({
transitionPlan: 'invoke-lifecycles', ← All routes
routes: [...]
})
Per-route configuration:
{
path: 'users/:id',
component: UserDetail,
transitionPlan: 'invoke-lifecycles' ← Just this route
}
Per-navigation override:
router.load('users/2', {
transitionPlan: 'invoke-lifecycles' ← Just this navigation
});
REAL-WORLD EXAMPLE: User Profile Tabs
═══════════════════════════════════════════════════════════
Component:
class UserProfile implements IRouteViewModel {
userId: string;
userData: User;
selectedTab = 'overview'; ← Component state
loading(params: Params) {
if (this.userId !== params.id) {
// Different user - fetch new data
this.userId = params.id;
this.userData = await fetchUser(params.id);
}
// Update tab from URL
this.selectedTab = params.tab || 'overview';
}
}
Routes:
{
path: 'users/:id/:tab?',
component: UserProfile,
transitionPlan: 'invoke-lifecycles' ← Preserve state
}
Navigation:
/users/123/overview → /users/123/posts
└─ Same user, keep loaded data, just update tab
/users/123/posts → /users/456/posts
└─ Different user, fetch new data in loading()
WHEN TO USE EACH
═══════════════════════════════════════════════════════════
Use 'replace' when:
✓ You want clean state each time
✓ Component has complex initialization
✓ Different params mean completely different data
✓ You don't trust yourself to handle reuse correctly
Use 'invoke-lifecycles' when:
✓ Only parameters change (same logical entity)
✓ You want to preserve UI state (scroll, selections)
✓ Performance matters (frequent navigation)
✓ You have good loading() logic that handles updates
COMMON PITFALL
═══════════════════════════════════════════════════════════
// ✗ BAD: Doesn't update when params change
class ProductDetail implements IRouteViewModel {
product: Product;
constructor() {
this.product = fetchProduct(params.id); ← params not available!
}
}
// ✓ GOOD: Updates on every navigation
class ProductDetail implements IRouteViewModel {
product: Product;
loading(params: Params) {
this.product = await fetchProduct(params.id); ← Correct!
}
}URL: /products/42/reviews?sort=date&page=2#reviews-section
\_______/\__/\______/\__________________/\_____________/
│ │ │ │ │
path param path query fragment
PARSING
═══════════════════════════════════════════════════════════
Router processes URL:
┌────────────────────────────────┐
│ Path segments: [products, 42, │
│ reviews] │
│ Path params: {id: '42'} │
│ Query params: {sort: 'date', │
│ page: '2'} │
│ Fragment: 'reviews-section'│
└────────────────────────────────┘
ROUTE MATCHING
═══════════════════════════════════════════════════════════
Configuration:
{
path: 'products/:id',
component: ProductDetail,
routes: [
{ path: 'reviews', component: Reviews }
]
}
Match result:
┌─────────────────────────────────────────┐
│ Route Tree: │
│ products (:id = '42') │
│ └─ reviews │
│ │
│ Params object: │
│ { id: '42' } │
│ │
│ Query object: │
│ { sort: 'date', page: '2' } │
└─────────────────────────────────────────┘
ACCESS IN COMPONENT
═══════════════════════════════════════════════════════════
Method 1: Lifecycle hooks
──────────────────────────────────────────
class ProductDetail implements IRouteViewModel {
productId: string;
canLoad(params: Params, next: RouteNode) {
// Path parameters
this.productId = params.id; // '42'
// Query parameters
const sort = next.queryParams.get('sort'); // 'date'
const page = next.queryParams.get('page'); // '2'
// Fragment
const fragment = next.fragment; // 'reviews-section'
return true;
}
}
Method 2: ICurrentRoute
──────────────────────────────────────────
import { ICurrentRoute } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
class ProductDetail {
private readonly currentRoute = resolve(ICurrentRoute);
attached() {
// Current path
console.log(this.currentRoute.path); // 'products/42/reviews'
// Parameters (includes all from parent routes)
const params = this.currentRoute.parameterInformation[0].params;
console.log(params.get('id')); // '42'
// Query string (need to parse)
const url = this.currentRoute.url;
const queryString = url.split('?')[1]; // 'sort=date&page=2'
}
}
Method 3: getRouteParameters (aggregates hierarchy)
────────────────────────────────────────────────────
import { IRouteContext } from '@aurelia/router';
import { resolve } from '@aurelia/kernel';
class NestedComponent {
private readonly context = resolve(IRouteContext);
attached() {
// Get all params from entire route hierarchy
const allParams = this.context.getRouteParameters<{
companyId: string; // From /companies/:companyId
projectId: string; // From /projects/:projectId
userId: string; // From /users/:userId
}>({
includeQueryParams: true // Also include ?foo=bar
});
console.log(allParams.companyId); // Nearest definition wins
}
}
PARAMETER TYPES
═══════════════════════════════════════════════════════════
All parameters are strings!
─────────────────────────────────────────
URL: /products/42?count=10&active=true
params.id // '42' (string, not number!)
params.count // '10' (string, not number!)
params.active // 'true' (string, not boolean!)
Always convert:
const id = Number(params.id);
const count = parseInt(params.count, 10);
const active = params.active === 'true';
PARAMETER BINDING WITH load
═══════════════════════════════════════════════════════════
Template:
<a load="route: products; params.bind: {id: productId}">
View Product
</a>
Component:
productId = 42;
Generated URL:
/products/42
With multiple params:
<a load="route: items;
params.bind: {
id: itemId,
category: itemCategory,
extra: 'value'
}">
View Item
</a>
Route: /items/:id/:category?
Generated: /items/42/electronics?extra=value
│ │ └─ query (not in path)
│ └─ matches :category
└─ matches :id
PROGRAMMATIC WITH OPTIONS
═══════════════════════════════════════════════════════════
router.load('products/42', {
queryParams: {
sort: 'price',
page: 1
},
fragment: 'reviews'
});
Generated URL:
/products/42?sort=price&page=1#reviews
Or with structured instruction:
router.load({
component: 'products',
params: { id: 42 },
children: [
{ component: 'reviews' }
]
}, {
queryParams: { sort: 'date' }
});
Generated URL:
/products/42/reviews?sort=date
PARAMETER CONSTRAINTS
═══════════════════════════════════════════════════════════
Validate during routing:
{
path: 'products/:id{{^\\d+$}}', // Only digits
component: ProductDetail
}
URL: /products/42 ✓ Matches
URL: /products/abc ✗ Doesn't match, goes to fallback
Custom validation in component:
canLoad(params: Params) {
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) {
return 'not-found'; // Redirect to 404
}
return true;
}export class CustomPropertyCustomAttribute {
// Custom logic can be added here
}import { INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class RedSquareCustomAttribute {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
// Set fixed dimensions and a red background on initialization
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}<import from="./red-square"></import>
<div red-square></div>import { INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class RedSquareCustomAttribute {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({ name: 'red-square' })
export class RedSquare {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}import { INode, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class RedSquare {
public static readonly $au: CustomAttributeStaticAuDefinition = {
type: 'custom-attribute',
name: 'red-square'
};
private element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({ name: 'red-square' })
export class RedSquare {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}import { customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({ name: 'red-square', aliases: ['redify', 'redbox'] })
export class RedSquare {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}<div red-square></div>
<div redify></div>
<div redbox></div>import { INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class HighlightCustomAttribute {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
public value: string;
constructor() {
// Apply default highlighting style
this.element.style.backgroundColor = 'yellow';
this.element.style.padding = '2px 4px';
this.element.style.borderRadius = '3px';
}
binding() {
// Override default color if a specific color is provided
if (this.value) {
this.element.style.backgroundColor = this.value;
}
}
}<import from="./highlight"></import>
<!-- Uses default yellow highlighting -->
<span highlight>Important text</span>
<!-- Uses custom color -->
<span highlight="lightblue">Custom highlighted text</span>import { bindable, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class HighlightCustomAttribute {
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
@bindable() public value: string;
constructor() {
// Apply default highlighting style
this.element.style.backgroundColor = 'yellow';
this.element.style.padding = '2px 4px';
this.element.style.borderRadius = '3px';
this.element.style.transition = 'background-color 0.3s ease';
}
bound() {
if (this.value) {
this.element.style.backgroundColor = this.value;
}
}
valueChanged(newValue: string, oldValue: string) {
this.element.style.backgroundColor = newValue || 'yellow';
}
}<import from="./highlight"></import>
<!-- Color changes reactively based on view model property -->
<span highlight.bind="selectedColor">Dynamic highlighting</span>import { bindable, INode, BindingMode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class InputWrapperCustomAttribute {
@bindable({ mode: BindingMode.twoWay }) public value: string = '';
@bindable({ mode: BindingMode.toView }) public placeholder: string = '';
@bindable({ mode: BindingMode.fromView }) public isValid: boolean = true;
@bindable({ mode: BindingMode.oneTime }) public label: string = '';
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
// ... implementation
}import { bindable, customAttribute, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({ name: 'color-square', defaultProperty: 'color' })
export class ColorSquareCustomAttribute {
@bindable() public color: string = 'red';
@bindable() public size: string = '100px';
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
this.applyStyles();
}
bound() {
this.applyStyles();
}
colorChanged(newColor: string) {
this.element.style.backgroundColor = newColor;
}
sizeChanged(newSize: string) {
this.element.style.width = this.element.style.height = newSize;
}
private applyStyles() {
this.element.style.width = this.element.style.height = this.size;
this.element.style.backgroundColor = this.color;
}
}<import from="./color-square"></import>
<!-- Using a literal value -->
<div color-square="blue"></div>
<!-- Or binding the value dynamically -->
<div color-square.bind="myColour"></div>import { bindable, INode, coercer } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class ValidatedInputCustomAttribute {
// Custom value transformation
@bindable({
set: (value: string) => value?.trim().toLowerCase()
}) public email: string = '';
// Range clamping
@bindable({
set: (value: number) => Math.max(0, Math.min(100, value))
}) public progress: number = 0;
// Built-in type coercion (automatic number conversion)
@bindable({ type: Number }) public count: number = 0;
// Explicit coercion with nullable handling
@bindable({
type: Number,
nullable: false // Won't coerce null/undefined to 0
}) public price: number;
// Custom coercer function
@bindable({
set: Boolean // Converts any value to boolean
}) public isActive: boolean;
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
}@customAttribute('typed-inputs')
export class TypedInputsCustomAttribute {
// Date parsing coercion
@bindable({
set: (value: string | Date) => {
if (typeof value === 'string') {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
return value;
}
}) public startDate: Date | null;
// Array coercion from comma-separated strings
@bindable({
set: (value: string | string[]) => {
return typeof value === 'string'
? value.split(',').map(s => s.trim())
: value;
}
}) public tags: string[] = [];
}import { bindable } from '@aurelia/runtime-html';
export class DataVisualizationCustomAttribute {
@bindable({ callback: 'onDataUpdate' }) public dataset: any[] = [];
@bindable({ callback: 'onConfigChange' }) public config: any = {};
onDataUpdate(newData: any[], oldData: any[]) {
// Handle data changes
this.redrawChart();
}
onConfigChange(newConfig: any, oldConfig: any) {
// Handle configuration changes
this.updateChartSettings();
}
}<import from="./color-square"></import>
<!-- Basic property binding -->
<div color-square="color.bind: myColor; size.bind: mySize;"></div>
<!-- Mix of binding modes -->
<div advanced-input="
value.two-way: inputValue;
placeholder.to-view: placeholderText;
maxLength.one-time: 50;
"></div><!-- Value converters and binding behaviors -->
<div chart-widget="
data.bind: chartData | sortBy:'date' & debounce:500;
config.bind: chartConfig;
theme.bind: currentTheme;
"></div>
<!-- Complex expressions -->
<div validator="
rules.bind: validationRules;
isEnabled.bind: userRole === 'admin' || isOwner;
onError.bind: errors => handleValidationErrors(errors);
"></div>
<!-- Object literals and arrays -->
<div data-table="
columns.bind: [
{ field: 'name', title: 'Name' },
{ field: 'email', title: 'Email' }
];
options.bind: {
pageSize: 10,
sortable: true,
filterable: currentUser.isAdmin
};
"></div><!-- Escape colons in URLs -->
<div url-handler="baseUrl: http\://example.com\:8080/api;"></div>
<!-- Alternative: use binding for complex values -->
<div url-handler="baseUrl.bind: apiBaseUrl;"></div>@customAttribute({
name: 'sql-query',
noMultiBindings: true // Treats entire value as single string
})
export class SqlQueryCustomAttribute {
public value: string; // Receives: "SELECT * FROM users WHERE role: 'admin'"
}<!-- This won't be parsed as bindings due to noMultiBindings: true -->
<div sql-query="SELECT * FROM users WHERE role: 'admin'"></div>import { customAttribute, INode, BindingMode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({
name: 'advanced-input',
bindables: {
value: { mode: BindingMode.twoWay, primary: true },
placeholder: { mode: BindingMode.toView },
validation: { callback: 'validateInput' }
}
})
export class AdvancedInputCustomAttribute {
public value: string;
public placeholder: string;
public validation: any;
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
validateInput(newValidation: any, oldValidation: any) {
// Handle validation changes
}
}import { INode, BindingMode, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class AdvancedInput {
public static readonly $au: CustomAttributeStaticAuDefinition = {
type: 'custom-attribute',
name: 'advanced-input',
bindables: {
value: { mode: BindingMode.twoWay, primary: true },
placeholder: { mode: BindingMode.toView },
validation: { callback: 'validateInput' }
}
};
public value: string;
public placeholder: string;
public validation: any;
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
validateInput(newValidation: any, oldValidation: any) {
// Handle validation changes
}
}import { bindable, INode, customAttribute, ICustomAttributeController, IHydratedController } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute({ name: 'lifecycle-demo' })
export class LifecycleDemoCustomAttribute {
@bindable() public value: string = '';
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
created(controller: ICustomAttributeController) {
// Called when the attribute instance is created
console.log('Custom attribute created');
}
binding(initiator: IHydratedController, parent: IHydratedController) {
// Called when binding begins - good for setup
console.log('Starting to bind');
this.applyInitialValue();
}
bound(initiator: IHydratedController, parent: IHydratedController) {
// Called after binding is complete
console.log('Binding complete');
}
attaching(initiator: IHydratedController, parent: IHydratedController) {
// Called before DOM attachment
console.log('About to attach to DOM');
}
attached(initiator: IHydratedController) {
// Called after DOM attachment - good for DOM manipulation
this.initializeThirdPartyLibrary();
}
valueChanged(newValue: string, oldValue: string) {
// Called whenever the value changes
this.updateDisplay();
}
detaching(initiator: IHydratedController, parent: IHydratedController) {
// Called before DOM detachment - good for cleanup
this.cleanupEventListeners();
}
unbinding(initiator: IHydratedController, parent: IHydratedController) {
// Called when unbinding - good for final cleanup
console.log('About to unbind');
this.finalCleanup();
}
private applyInitialValue() {
this.element.textContent = this.value;
}
private updateDisplay() {
this.element.textContent = this.value;
}
private initializeThirdPartyLibrary() {
// Initialize any third-party libraries that need DOM access
}
private cleanupEventListeners() {
// Remove event listeners to prevent memory leaks
}
private finalCleanup() {
// Final cleanup before the attribute is destroyed
}
}import { bindable, customAttribute } from '@aurelia/runtime-html';
@customAttribute('batch-processor')
export class BatchProcessorCustomAttribute {
@bindable() public prop1: string;
@bindable() public prop2: number;
@bindable() public prop3: boolean;
// Called when any bindable property changes (batched until next microtask)
// This is the most efficient way to handle multiple property changes
propertiesChanged(changes: Record<string, { newValue: unknown; oldValue: unknown }>) {
console.log('Properties changed:', changes);
// Example output: { prop1: { newValue: 'new', oldValue: 'old' } }
// Process all changes at once for better performance
this.processBatchedChanges(changes);
}
// Called for every property change (immediate, not batched)
// Note: Both propertiesChanged AND individual callbacks will fire
propertyChanged(key: PropertyKey, newValue: unknown, oldValue: unknown) {
console.log(`Property ${String(key)} changed from ${oldValue} to ${newValue}`);
}
// Individual property callbacks still work alongside aggregated callbacks
prop1Changed(newValue: string, oldValue: string) {
console.log('Prop1 individual callback');
}
private processBatchedChanges(changes: Record<string, any>) {
// Efficiently handle multiple property changes
// Example: Update a chart that depends on multiple data properties
if ('prop1' in changes || 'prop2' in changes) {
this.updateVisualization();
}
}
}import { INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class RedSquareCustomAttribute {
// Resolve the host element safely, even in Node.js environments
private element: HTMLElement = resolve(INode) as HTMLElement;
constructor() {
// Now you can modify the host element directly
this.element.style.width = this.element.style.height = '100px';
this.element.style.backgroundColor = 'red';
}
}<div foo="1">
<center>
<div foo="3">
<div bar="2"></div>
</div>
</center>
</div>import { CustomAttribute, INode, customAttribute } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@customAttribute('bar')
export class Bar {
private readonly host: HTMLElement = resolve(INode) as HTMLElement;
binding() {
// Find the closest ancestor that has the 'foo' custom attribute
const closestFoo = CustomAttribute.closest(this.host, 'foo');
if (closestFoo) {
console.log('Found foo attribute:', closestFoo.viewModel);
// Access the attribute's value
console.log('Foo value:', closestFoo.viewModel.value);
}
}
}import { CustomAttribute, INode, customAttribute } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
import { Foo } from './foo';
@customAttribute('bar')
export class Bar {
private readonly host: HTMLElement = resolve(INode) as HTMLElement;
binding() {
// Find the closest ancestor that is an instance of the Foo custom attribute
const parentFoo = CustomAttribute.closest(this.host, Foo);
if (parentFoo) {
// parentFoo.viewModel is now strongly typed as Foo
parentFoo.viewModel.someMethod();
parentFoo.viewModel.someProperty = 'new value';
}
}
}@customAttribute('form-section')
export class FormSectionCustomAttribute {
@bindable() public sectionName: string;
@bindable() public isValid: boolean = true;
validateSection(): boolean {
// Section-specific validation logic
return this.isValid;
}
}
@customAttribute('form-field')
export class FormFieldCustomAttribute {
@bindable() public fieldName: string;
@bindable() public required: boolean = false;
private readonly host = resolve(INode) as HTMLElement;
validate(): boolean {
// Find the parent form section
const section = CustomAttribute.closest(this.host, FormSectionCustomAttribute);
if (section) {
console.log(`Validating field ${this.fieldName} in section ${section.viewModel.sectionName}`);
// Coordinate with parent section validation
const isValid = this.performFieldValidation();
section.viewModel.isValid = section.viewModel.isValid && isValid;
return isValid;
}
return this.performFieldValidation();
}
private performFieldValidation(): boolean {
// Field-specific validation logic
return true;
}
}<form>
<div form-section="section-name: personal; is-valid.two-way: personalSectionValid">
<input form-field="field-name: firstName; required: true" />
<input form-field="field-name: lastName; required: true" />
</div>
<div form-section="section-name: contact; is-valid.two-way: contactSectionValid">
<input form-field="field-name: email; required: true" />
</div>
</form>import { templateController, IViewFactory, ISyntheticView, IRenderLocation, bindable, ICustomAttributeController } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@templateController('permission')
export class PermissionTemplateController {
@bindable() public userRole: string;
@bindable() public requiredRole: string;
public readonly $controller!: ICustomAttributeController<this>;
private view: ISyntheticView;
private readonly factory = resolve(IViewFactory);
private readonly location = resolve(IRenderLocation);
bound() {
this.updateView();
}
userRoleChanged() {
if (this.$controller.isActive) {
this.updateView();
}
}
requiredRoleChanged() {
if (this.$controller.isActive) {
this.updateView();
}
}
private updateView() {
const hasPermission = this.userRole === this.requiredRole;
if (hasPermission) {
if (!this.view) {
this.view = this.factory.create().setLocation(this.location);
}
if (!this.view.isActive) {
this.view.activate(this.view, this.$controller, this.$controller.scope);
}
} else {
if (this.view?.isActive) {
this.view.deactivate(this.view, this.$controller);
}
}
}
unbinding() {
if (this.view?.isActive) {
this.view.deactivate(this.view, this.$controller);
}
}
}<div permission="user-role.bind: currentUser.role; required-role: admin">
<h2>Admin Panel</h2>
<p>Only admins can see this content</p>
</div>import { IViewFactory, ISyntheticView, IRenderLocation, type CustomAttributeStaticAuDefinition } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
export class PermissionTemplateController {
public static readonly $au: CustomAttributeStaticAuDefinition = {
type: 'custom-attribute',
name: 'permission',
isTemplateController: true,
bindables: ['userRole', 'requiredRole']
};
// ... implementation same as above
}import { customAttribute } from '@aurelia/runtime-html';
@customAttribute({
name: 'simple-url',
noMultiBindings: true
})
export class SimpleUrlCustomAttribute {
public value: string; // Will receive the entire attribute value as a string
}<!-- With noMultiBindings: true, this won't be parsed as bindings -->
<a simple-url="https://example.com:8080/path">Link</a>import { customAttribute } from '@aurelia/runtime-html';
import { SomeService } from './some-service';
@customAttribute({
name: 'dependent-attr',
dependencies: [SomeService]
})
export class DependentAttributeCustomAttribute {
// SomeService will be registered when this attribute is used
}import { templateController, IViewFactory, bindable } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
@templateController({
name: 'isolated-scope',
containerStrategy: 'new' // Creates a new container for child views
})
export class IsolatedScopeTemplateController {
@bindable() public isolatedServices: boolean = true;
private readonly factory = resolve(IViewFactory);
private readonly location = resolve(IRenderLocation);
bound() {
// Views created by this template controller will have their own container
// allowing for isolated service instances
const view = this.factory.create().setLocation(this.location);
// Services registered in child views won't interfere with parent
}
}
@templateController({
name: 'shared-scope',
containerStrategy: 'reuse' // Reuses parent container (default)
})
export class SharedScopeTemplateController {
// Child views share the same container as the parent
// More efficient but services are shared
}// Good candidate for 'new' container strategy
@templateController('plugin-host')
export class PluginHostTemplateController {
@bindable() public pluginConfig: PluginConfiguration;
// Each plugin needs isolated services to prevent conflicts
// Plugin A's HttpClient shouldn't interfere with Plugin B's
}
// Good candidate for 'reuse' strategy (default)
@templateController('simple-conditional')
export class SimpleConditionalTemplateController {
@bindable() public condition: boolean;
// Simple conditional rendering doesn't need service isolation
// Sharing parent container is more efficient
}import { CustomAttributeDefinition } from '@aurelia/runtime-html';
const def = CustomAttributeDefinition.getDefinition(PluginHostCustomAttribute);
if (def.containerStrategy === 'new') {
console.debug('PluginHost will isolate services per instance.');
}import { BindingMode, CustomAttribute } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
const TrackingAttribute = CustomAttribute.define({
name: 'tracking',
bindables: {
category: { mode: BindingMode.oneTime },
data: { mode: BindingMode.twoWay }
},
watches: [
{ expression: 'data.total', callback: 'logChange' }
],
dependencies: [AnalyticsService]
}, class {
private readonly analytics = resolve(AnalyticsService);
public logChange(newValue: unknown) {
this.analytics.track(newValue);
}
});
Aurelia.register(TrackingAttribute);import { bindable, customAttribute, watch } from '@aurelia/runtime-html';
@customAttribute('data-processor')
export class DataProcessorCustomAttribute {
@bindable() public data: any[];
@bindable() public config: any;
@watch('data', { immediate: true })
@watch('config')
onDataOrConfigChange(newValue: any, oldValue: any, propertyName: string) {
console.log(`${propertyName} changed from`, oldValue, 'to', newValue);
this.reprocessData();
}
private reprocessData() {
// Process data based on current data and config
}
}import { customAttribute, bindable, INode } from '@aurelia/runtime-html';
import { resolve, ILogger } from '@aurelia/kernel';
// Import the third-party slider library (this is a hypothetical example)
import AwesomeSlider from 'awesome-slider';
@customAttribute('awesome-slider')
export class AwesomeSliderCustomAttribute {
// Allow dynamic options to be bound from the view
@bindable() public options: any = {};
// The instance of the third-party slider
private sliderInstance: any;
// Safely resolve the host element
private readonly element: HTMLElement = resolve(INode) as HTMLElement;
private readonly logger = resolve(ILogger);
attached() {
// Initialize the slider when the element is attached to the DOM.
// This ensures that the DOM is ready for manipulation.
try {
this.sliderInstance = new AwesomeSlider(this.element, this.options);
} catch (error) {
this.logger.error('Failed to initialize AwesomeSlider:', error);
}
}
optionsChanged(newOptions: any, oldOptions: any) {
// Update the slider if its configuration changes at runtime.
// This callback is triggered when the bound `options` property changes.
if (this.sliderInstance && typeof this.sliderInstance.updateOptions === 'function') {
this.sliderInstance.updateOptions(newOptions);
}
}
detaching() {
// Clean up the slider instance when the element is removed from the DOM.
// This prevents memory leaks and removes event listeners.
if (this.sliderInstance && typeof this.sliderInstance.destroy === 'function') {
this.sliderInstance.destroy();
this.sliderInstance = null;
}
}
}// ✅ Good - focused on DOM enhancement
@customAttribute('tooltip')
export class TooltipCustomAttribute {
@bindable() public text: string;
// Implementation focused on showing/hiding tooltip
}
// ❌ Bad - mixing business logic
@customAttribute('tooltip')
export class TooltipCustomAttribute {
@bindable() public userId: string;
async fetchUserData() {
// Don't do data fetching in custom attributes
return await this.api.getUser(this.userId);
}
}@customAttribute('performance-optimized')
export class PerformanceOptimizedCustomAttribute {
@bindable() public width: string;
@bindable() public height: string;
@bindable() public color: string;
// ✅ Batch multiple property changes
propertiesChanged(changes: Record<string, any>) {
const element = this.element;
if ('width' in changes) element.style.width = changes.width.newValue;
if ('height' in changes) element.style.height = changes.height.newValue;
if ('color' in changes) element.style.backgroundColor = changes.color.newValue;
}
}@customAttribute('event-handler')
export class EventHandlerCustomAttribute {
private eventListener: EventListener;
private thirdPartyInstance: any;
attached() {
this.eventListener = this.handleClick.bind(this);
this.element.addEventListener('click', this.eventListener);
this.thirdPartyInstance = new SomeLibrary(this.element);
}
detaching() {
// ✅ Always clean up
this.element.removeEventListener('click', this.eventListener);
this.thirdPartyInstance?.destroy();
this.thirdPartyInstance = null;
}
}@customAttribute('robust-attribute')
export class RobustCustomAttribute {
@bindable() public config: any;
private readonly logger = resolve(ILogger);
attached() {
try {
this.initializeFeature();
} catch (error) {
this.logger.error('Failed to initialize feature:', error);
// Fallback behavior
this.element.classList.add('feature-unavailable');
}
}
configChanged(newConfig: any) {
if (!this.isValidConfig(newConfig)) {
this.logger.warn('Invalid configuration provided');
return;
}
this.updateConfiguration(newConfig);
}
}// Example test structure
describe('MyCustomAttribute', () => {
it('should initialize correctly', () => { /* ... */ });
it('should handle property changes', () => { /* ... */ });
it('should clean up on detach', () => { /* ... */ });
it('should handle invalid input gracefully', () => { /* ... */ });
});// ✅ Strong typing with interfaces
interface ChartConfiguration {
readonly type: 'line' | 'bar' | 'pie';
readonly data: ChartData;
readonly options?: ChartOptions;
}
@customAttribute('chart')
export class ChartCustomAttribute {
@bindable() public config: ChartConfiguration;
// ✅ Typed change handlers
configChanged(newConfig: ChartConfiguration, oldConfig: ChartConfiguration) {
// TypeScript will catch type errors
if (newConfig.type !== oldConfig?.type) {
this.recreateChart(newConfig);
}
}
}interface SliderOptions {
min: number;
max: number;
step: number;
}
@customAttribute('typed-slider')
export class TypedSliderCustomAttribute {
@bindable() public options: SliderOptions = { min: 0, max: 100, step: 1 };
@bindable() public value: number = 0;
optionsChanged(newOptions: SliderOptions, oldOptions: SliderOptions) {
// Type-safe change handling
}
}@customAttribute('computed-display')
export class ComputedDisplayCustomAttribute {
@bindable() public firstName: string = '';
@bindable() public lastName: string = '';
// Computed bindable using getter
@bindable()
get fullName(): string {
return `${this.firstName} ${this.lastName}`.trim();
}
// Optional setter for two-way binding
set fullName(value: string) {
const parts = value.split(' ');
this.firstName = parts[0] || '';
this.lastName = parts.slice(1).join(' ') || '';
}
fullNameChanged(newName: string) {
// Responds to computed property changes
this.updateDisplay(newName);
}
}@customAttribute('base-widget')
export class BaseWidgetCustomAttribute {
@bindable() public theme: string = 'default';
@bindable() public size: 'small' | 'medium' | 'large' = 'medium';
}
@customAttribute('advanced-widget')
export class AdvancedWidgetCustomAttribute extends BaseWidgetCustomAttribute {
@bindable() public animation: boolean = true;
@bindable() public tooltip: string = '';
// Inherits theme and size bindables from parent class
// Can override parent behavior if needed
themeChanged(newTheme: string, oldTheme: string) {
super.themeChanged?.(newTheme, oldTheme);
this.applyAdvancedThemeFeatures(newTheme);
}
}@customAttribute('robust-widget')
export class RobustWidgetCustomAttribute {
private disposables: Array<() => void> = [];
private readonly logger = resolve(ILogger);
created(controller: ICustomAttributeController) {
this.logger.debug('Widget attribute created', { controller });
}
attached() {
try {
this.initializeWidget();
} catch (error) {
this.logger.error('Widget initialization failed', error);
this.fallbackToDefaultBehavior();
}
}
detaching() {
// Clean up all disposables
this.disposables.forEach(dispose => {
try {
dispose();
} catch (error) {
this.logger.warn('Cleanup error', error);
}
});
this.disposables.length = 0;
}
private addDisposable(dispose: () => void) {
this.disposables.push(dispose);
}
}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();// 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>// 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>// 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>// 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>// 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>// 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>// 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>// 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>// 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>Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.
change events with mutation observationresponse.ok to handle server or network errors.User Input → DOM Event → Observer → Binding → View Model → Reactive Updates
↑ ↓
Form Element ← DOM Update ← Binding ← Property Change ← View Model<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 });
}
}
}<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;
}<div class="form-group">
<label for="age">Age:</label>
<input id="age"
type="number"
value-as-number.bind="age"
min="18"
max="120" />
</div>
<div class="form-group">
<label for="birthdate">Birth Date:</label>
<input id="birthdate"
type="date"
value-as-date.bind="birthDate" />
</div>
<div class="form-group">
<label for="appointment">Appointment Time:</label>
<input id="appointment"
type="datetime-local"
value-as-date.bind="appointmentTime" />
</div>export class ProfileForm {
age = 25;
birthDate = new Date('1998-01-01');
appointmentTime = new Date();
// Computed property demonstrating reactive updates
get isAdult(): boolean {
return this.age >= 18;
}
}<form>
<label>User value:</label><br />
<input type="text" value.bind="userValue" />
</form><form>
<label>User value:</label><br />
<input type="text" value.bind="userValue" placeholder.bind="myPlaceholder" />
</form><form>
<label>Comments:</label><br />
<textarea value.bind="textAreaValue"></textarea>
</form>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>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>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']);
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" />
<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>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'
}
];
// Record: resource -> Set<action>
selectedPermissions: Record<string, Set<string>> = {};
constructor() {
for (const permission of this.permissions) {
this.selectedPermissions[permission.resource] = new Set();
}
this.selectedPermissions['users'].add('read');
this.selectedPermissions['posts'].add('read');
this.selectedPermissions['posts'].add('create');
}
get permissionSummary() {
const summary: Array<{ resource: string; actions: string[] }> = [];
Object.entries(this.selectedPermissions).forEach(([resource, actions]) => {
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"
model.bind="action"
checked.bind="selectedPermissions[permission.resource]" />
${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>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>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>import { resolve } from '@aurelia/kernel';
import { observable, computed } 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
@computed('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>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>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
@computed('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
}
}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>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>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>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">
<au-compose
component.bind="currentStep.component"
model.bind="{
data: currentStep.data,
updateData: updateStepData,
isValid: currentStep.isValid
}">
</au-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><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>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);
}
}
}<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]] : [];
}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>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>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>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>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 || '';
}
}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();
}
}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);
}
}<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>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);
}
}/* 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;
}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
}
}