arrow-left

Only this pageAll pages
gitbookPowered by GitBook
triangle-exclamation
Couldn't generate the PDF for 597 pages, generation stopped at 100.
Extend with 50 more pages.
1 of 100

The Aurelia 2 Docs

Loading...

Introduction

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Getting Started

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Coming from Another Framework?

Loading...

Loading...

Loading...

Templates

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...

Components

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Getting to know Aurelia

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Template references

Template references in Aurelia 2 offer a powerful and declarative mechanism to establish direct links between elements in your HTML templates and properties in your JavaScript or TypeScript view models. Using the ref attribute, you can easily obtain references to specific DOM elements, custom element instances, custom attribute instances, or even Aurelia controllers, enabling efficient DOM manipulation and streamlined interaction with template elements.

hashtag
Declaring Template References

hashtag
Basic Usage: Referencing DOM Elements

To create a template reference to a standard HTML element, simply add the ref attribute to the element within your template. The value assigned to ref will be the name of the property in your view model that will hold the reference.

In this basic example, firstNameInput is declared as a template reference. Aurelia will automatically populate a property in your view model with the same name, making the <input> element directly accessible.

hashtag
Accessing References in Templates

Template references become immediately available for use within the template itself. You can directly access properties and methods of the referenced element using the reference name.

For example, to dynamically display the current value of the firstNameInput field:

As the user types in the input field, the <p> element will update in real-time, displaying the current value accessed through firstNameInput.value.

hashtag
Accessing References in View Models

To access a template reference in your view model, you need to declare a property in your view model class that matches the reference name you used in the template. For TypeScript projects, it's strongly recommended to explicitly type this property for enhanced type safety and code maintainability.

Important Notes:

  • Property Naming: The property name in your view model must exactly match the value of the ref attribute in your template (firstNameInput in the example above).

  • Type Safety: In TypeScript, always declare the type of your template reference property (e.g., HTMLInputElement, HTMLDivElement, MyCustomElement). This improves code readability and helps catch type-related errors early.

hashtag
Advanced Usage: Referencing Components and Controllers

Aurelia's ref attribute extends beyond simple DOM elements. It provides powerful options to reference component instances and controllers of custom elements and attributes.

hashtag
1. component.ref: Referencing Custom Element Instances (View Models)

To obtain a reference to the view model instance of a custom element, use component.ref="expression". This was previously known as view-model.ref in Aurelia v1.

In your view model:

component.ref is invaluable when you need to directly interact with the logic and data encapsulated within a custom element's view model from a parent component.

hashtag
2. custom-attribute.ref: Referencing Custom Attribute Instances (View Models)

Similarly, to reference the view model instance of a custom attribute applied to an element, use custom-attribute.ref="expression".

In your view model:

custom-attribute.ref is useful when you need to interact with the behavior or state managed by a custom attribute from the surrounding view model.

hashtag
3. controller.ref: Referencing Aurelia Controller Instances (Advanced)

For more advanced scenarios, controller.ref="expression" allows you to access the Aurelia Controller instance of a custom element. The Controller provides access to Aurelia's internal workings and lifecycle management for the element. This is less commonly needed but can be powerful for framework-level integrations or very specific use cases.

In your view model:

controller.ref provides access to the Aurelia Controller, which is an advanced API and typically used for framework extension or very specific control over component lifecycle and binding. For most application development, component.ref or direct DOM element references are sufficient.

hashtag
Practical Applications and Benefits

Template references significantly enhance Aurelia development by providing a clean, framework-integrated way to interact with elements and components. They offer several key advantages:

  • Direct DOM Manipulation: Template references provide a structured and type-safe way to obtain direct references to DOM elements, which is essential for tasks like:

    • Focusing input fields programmatically (elementRef.focus()).

    • Imperative DOM manipulation when integrating with third-party libraries that require direct element access (e.g., initializing jQuery plugins, interacting with canvas elements, etc.).

circle-info

By using template references, you move away from string-based DOM queries and embrace a more declarative and type-safe approach to DOM and component interaction within your Aurelia applications. This leads to more robust, maintainable, and efficient code, especially when dealing with complex UI interactions or integrations with external libraries.

Reactivity

Aurelia's reactivity system automatically tracks changes to your data and updates the UI efficiently. Unlike frameworks that use virtual DOM, Aurelia observes your data directly and surgically updates only what has changed.

hashtag
When to Use Which Reactivity Feature?

Aurelia offers several reactivity tools. Here's how to choose:

  • Lifecycle Timing: Template references are not available during the view model's constructor. They become available after the view is bound to the view model, typically in lifecycle hooks like bound() or later.

  • Fine-grained control over element properties and attributes.

  • Component Interaction: component.ref and custom-attribute.ref enable seamless communication and interaction between parent components and their children (custom elements and attributes). This allows for:

    • Calling methods on child component view models.

    • Accessing data and state within child components.

    • Building more complex and encapsulated component structures.

  • Simplified DOM Access: Template references eliminate the need for manual DOM queries using document.querySelector or similar methods within your view models. This leads to:

    • Cleaner and more readable view model code.

    • Reduced risk of brittle selectors that break if the template structure changes.

    • Improved maintainability and refactoring capabilities.

  • Integration with Third-Party Libraries: Many JavaScript libraries require direct DOM element references for initialization or interaction. Template references provide the ideal mechanism to obtain these references within an Aurelia application without resorting to less maintainable DOM query approaches.

  • hashtag
    Use simple properties (no decorator) when:
    • ✅ You only need UI updates - Properties bound in templates are automatically observed

    • ✅ Most common case - Just declare the property and bind it

    • ✅ Example: todos: Todo[] = [] with repeat.for="todo of todos"

    hashtag
    Use getters (computed) when:

    • ✅ Value depends on other properties and calculation is cheap

    • ✅ Automatic dependency tracking - no manual configuration needed

    • ✅ Example: get fullName() { return this.firstName + ' ' + this.lastName; }

    hashtag
    Use @computed decorator when:

    • ✅ Expensive calculations that should be cached

    • ✅ You want to explicitly control dependencies (not automatic)

    • ✅ Deep observation needed for nested objects

    • ✅ Example: Complex filtering, heavy aggregations

    hashtag
    Use @computed on methods when:

    • A method call in template needs dependency tracking

    • You want explicit tracked dependencies instead of proxy-based tracking

    • You want to disable tracking for a specific method call

    • Observation only activates when the method is called from an observation context (template, computed). Normal calls are unaffected

    • Example: complex filtering or formatting methods used directly from templates

    hashtag
    Use @observable when:

    • ✅ You need to run code when a property changes (side effects)

    • ✅ You want the propertyChanged(newValue, oldValue) callback

    • ✅ Examples: Validation, analytics tracking, syncing data

    hashtag
    Use watch() when:

    • ✅ Complex expressions - watching multiple properties or nested values

    • ✅ Need more flexibility than @observable

    • ✅ Examples: @watch('user.address.city'), @watch(vm => vm.total > 100)

    hashtag
    Use manual observation when:

    • ✅ Building libraries or advanced features

    • ✅ Need fine-grained control over subscription lifecycle

    • ✅ Performance critical code requiring optimization

    hashtag
    Automatic Change Detection

    Aurelia automatically observes properties that are bound in your templates. No decorators or special setup required:

    The ${todos.length}, value.bind="filter", and repeat.for="todo of todos" create automatic observation - Aurelia tracks changes to these properties and updates the UI accordingly.

    hashtag
    Computed Properties

    Getter properties automatically become reactive when their dependencies change:

    hashtag
    Decorator computed

    For some reason, when it's more preferrable to specify dependencies of a getter manually, rather than automatically tracked on read, you can use the decorator @computed to declare the dependencies, like the following example:

    You can also specify multiple properties as dependencies, like the following example:

    Basides the above basic usages, the computed decorator also supports a few more options, depending on the needs of an application.

    hashtag
    Method tracking with @computed

    When you call a method from a template (not a getter), use @computed to control dependency tracking for that call. Observation only activates when the method is called from an observation context (e.g. a template binding or another computed observation). A normal function call will not trigger any observation.

    Note: Using @computed on methods is currently experimental. The syntax and behavior may change before the final release.

    Behavior:

    • deps omitted (or undefined) uses proxy-based tracking.

    • deps: [] explicitly disables tracking for the decorated method/getter.

    • deps with strings enables explicit string-based dependency tracking.

    • deps with a getter function enables function-based dependency tracking.

    • Strings and functions cannot be mixed in the same deps declaration.

    • Stacking @computed on the same method overrides prior tracking metadata (last applied wins).

    hashtag
    Flush timing with flush

    Like how you can specify flush mode of computed getter with @computed({ flush: 'sync' }), flush mode of @computed can also be done in a similar way, like the following example:

    hashtag
    Deep observation with deep

    Sometimes you also want to automatically observe all properties of an object recursively, regardless at what level, deep option on the @computed decorator can be used to achieve this goal, like the following example:

    Now whenever _cart.items[].price or _cart.items[].quantity (or whatever else properties on each element in the items array), or _cart.gst changes, the total is considered dirty.

    [!WARNING] deep observation doesn't observe non-existent properties, which means newly added properties won't trigger any changes notification. Replace the entire object instead.

    hashtag
    Deep Observation

    Aurelia can observe nested object changes:

    hashtag
    Array Observation

    Arrays are automatically observed for mutations:

    hashtag
    When You Need @observable

    The @observable decorator is only needed when you want to react to property changes in your view-model code (not just the template):

    hashtag
    Effect Observation

    Create side effects that run when observed data changes:

    hashtag
    Manual Observation Control

    For advanced scenarios, manually control observation:

    hashtag
    Performance Considerations

    Aurelia's observation is highly optimized:

    • Batched Updates: Multiple changes are batched into single DOM updates

    • Surgical Updates: Only changed elements are updated, not entire component trees

    • Smart Detection: Observes only bound properties, not entire objects

    • No Virtual DOM: Direct DOM manipulation eliminates virtual DOM overhead

    hashtag
    Common Reactivity Patterns

    hashtag
    Pattern: Form Validation with @observable

    Use case: Validate input as the user types, show errors immediately.

    Why this works: @observable triggers validation automatically as users type. The isValid getter recomputes whenever errors change, enabling/disabling the submit button reactively.

    hashtag
    Pattern: Computed Filtering and Sorting

    Use case: Filter and sort a list based on user input without re-fetching data.

    Why this works: The filteredProducts getter automatically recomputes when any dependency changes. No manual refresh needed - the UI stays in sync with filters.

    hashtag
    Pattern: Syncing Data with @watch

    Use case: Keep related data in sync, like saving to localStorage or syncing with a server.

    Why this works: @watch observes both content and title, automatically saving changes. The pattern prevents data loss and provides user feedback.

    hashtag
    Pattern: Dependent Computations

    Use case: Chain computed properties where one depends on another.

    Why this works: Computed properties automatically form a dependency chain. When subtotal changes, discount updates, which updates afterDiscount, then tax, and finally total. All cascade automatically.

    hashtag
    Pattern: Optimized List Updates with @computed

    Use case: Expensive computations on large lists that should only recalculate when necessary.

    Why this works: @computed with explicit dependencies prevents unnecessary recalculations. Changing individual data point properties won't trigger recalculation - only changes to array length, date range, or metric do.

    hashtag
    Best Practices

    hashtag
    Choose the Right Tool

    • ✅ Start simple - Use plain properties and getters first

    • ✅ Add @observable only when you need side effects

    • ✅ Use @computed for expensive operations, not simple getters

    • ❌ Don't over-engineer - most scenarios don't need @watch or manual observation

    hashtag
    Keep Computations Pure

    • ✅ Computed getters should have no side effects

    • ✅ Same inputs should always produce same outputs

    • ❌ Don't modify state inside getters

    • ❌ Don't make API calls in computed properties

    hashtag
    Optimize Performance

    • ✅ Use @computed with explicit dependencies for expensive calculations

    • ✅ Debounce rapid changes (user input, scroll events)

    • ✅ Batch related updates together

    • ❌ Don't create unnecessary watchers

    hashtag
    Handle Async Operations

    • ✅ Use @watch or propertyChanged callbacks for async side effects

    • ✅ Track loading states during async operations

    • ✅ Handle errors gracefully

    • ❌ Don't make computed properties async

    hashtag
    What's Next

    • Learn about observing property changes in detail

    • Explore effect observation for advanced reactive patterns

    • Understand watching data strategies

    <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 { /* ... */ }
    }

    Forms

    Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.

    Component Recipes

    Template compilation

    Startup & enhancement

    Real-World Recipes

    Introduction

    Essentials

    Aurelia is a modern JavaScript framework that empowers you to build exceptional web applications with clean, maintainable code. If you're familiar with HTML, CSS, and modern JavaScript/TypeScript, this essentials guide will introduce you to Aurelia's core concepts and get you productive quickly.

    This section provides a practical overview of Aurelia's fundamental building blocks. Each concept links to comprehensive documentation where you can dive deeper, but these essentials give you everything needed to start building with confidence.

    hashtag
    What Makes Aurelia Different

    Aurelia stands out by embracing web standards and keeping things simple:

    • Standards-based: Built on modern web standards without unnecessary abstractions

    • Convention over configuration: Intuitive patterns that reduce boilerplate

    • Powerful templating: Rich binding system with excellent performance

    • Dependency injection: Clean, testable architecture out of the box

    hashtag
    Core Concepts

    hashtag

    Components are the building blocks of your Aurelia application. Learn how to create reusable UI elements with clean separation between view and view-model logic.

    hashtag

    Aurelia's templating system provides powerful data binding, event handling, and conditional rendering with a syntax that feels natural and expressive.

    hashtag

    Aurelia's dependency injection system promotes clean architecture by managing your application's services and their dependencies automatically.

    hashtag

    Understand how Aurelia's observation system automatically tracks changes and updates your UI efficiently without virtual DOM overhead.

    hashtag
    Next Steps

    Ready to build something? Try our to create your first Aurelia application. For comprehensive coverage of all features, explore the full documentation sections on , , and .

    The section contains step-by-step guides for building real applications, while covers advanced topics and best practices.

    Step 1: Project setup + app shell

    Set up Project Pulse, enable the router, and build the shared app shell.

    In this step you will create the project, enable the router, and build a shared layout component.

    hashtag
    1. Create the project

    hashtag

    focus custom attribute

    Bind an element's focus state with Aurelia's built-in focus custom attribute.

    focus is a built-in custom attribute that lets you bind an element’s focus state to a view-model property.

    By default it’s two-way:

    • setting the property focuses/blurs the element

    • focusing/blurring the element updates the property

    Components
    Templates
    Dependency Injection
    Reactivity
    Quick Start Guide
    Templates
    Components
    Getting to Know Aureliaarrow-up-right
    Tutorialsarrow-up-right
    Developer Guidesarrow-up-right
    2. Enable the router and active link styling

    Update src/main.ts to register the router and configure an active class for navigation links:

    Any <a load="..."> link will now get the is-active class when the route is active.

    hashtag
    3. Create the app shell

    Create the folders src/components and src/pages if you do not have them yet.

    Create src/components/app-shell.ts:

    Create src/components/app-shell.html:

    Next step: Step 2: Routing + nested layouts

    npx makes aurelia
    # Name: project-pulse
    # Select TypeScript
    cd project-pulse
    npm run dev
    hashtag
    Basic usage (two-way)

    hashtag
    One-way focus (recommended for “open a thing, focus an input”)

    Two-way focus can be surprising if you don’t want a blur to change your state. In that case, make it one-way:

    Now the input won’t write back to shouldFocusSearch when it blurs.

    hashtag
    Common pattern: focus when showing a panel

    hashtag
    Notes

    • The target must be focusable. For non-input elements, you may need tabindex="0".

    • Focusing can only happen once the element is connected to the document; focus handles this automatically (it will apply focus after attach if needed).

    import 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>

    Introduction

    Get acquainted with Aurelia, the documentation, and how to get started.

    hashtag
    What is Aurelia?

    Aurelia is the web framework that feels native to the browser. Built on web standards with zero overhead from virtual DOMs or magic abstractions, Aurelia delivers exceptional performance while keeping your code clean and maintainable.

    Why developers choose Aurelia:

    • Efficient bundle sizes with smart code splitting and async loading

    • Standards-based architecture that leverages browser capabilities

    • TypeScript-first with powerful dependency injection built-in

    • No breaking changes since 2.0 - stable, production-ready platform

    If you value web standards, performance, and developer experience over framework hype, Aurelia is built for you.

    hashtag
    Choose Your Path

    👋 New to Aurelia? Start with our - build a real app in 15 minutes.

    🔧 Want the concepts first? Begin with for focused introduction to core concepts.

    🚀 Migrating from another framework?

    • - Better performance, cleaner code

    • - All the simplicity, better performance

    • - Keep the good, lose the complexity

    ⚡ Just want to see it work? Try our for immediate setup.

    hashtag
    Why Aurelia Outperforms

    hashtag
    Performance That Matters

    • Direct DOM manipulation - no virtual DOM overhead

    • Batched rendering for optimal browser performance

    • Efficient memory usage - applications that don't drain batteries

    hashtag
    Standards-First Development

    • Web Components foundation - build for the future of the web

    • Modern JavaScript/TypeScript - no proprietary abstractions

    • Seamless third-party integration - works with any library

    hashtag
    Developer Experience

    • Powerful dependency injection built-in, no external libraries needed

    • Two-way data binding with unidirectional safety

    • Complete ecosystem - routing, validation, i18n, testing, CLI

    hashtag
    Production Ready

    • Used by enterprise companies worldwide

    • MIT licensed open-source with active development

    • Comprehensive testing tools built-in

    hashtag
    Built for the Future of Web Development

    Aurelia isn't chasing the latest trends - it's built on the fundamental technologies that power the web. By embracing web standards and leveraging browser capabilities, Aurelia applications are inherently future-proof and performant.

    Our focused approach means:

    • Thoughtful feature development - every addition serves a clear purpose

    • Direct access to the core team - your feedback directly shapes the framework

    • Stable, reliable releases - no breaking changes that disrupt your projects

    hashtag
    Direct DOM: Maximum Performance, Minimum Overhead

    Aurelia delivers superior performance by working directly with the browser's DOM instead of creating unnecessary abstraction layers.

    hashtag
    Why Direct DOM Wins

    Performance Advantages:

    • Zero virtual DOM overhead - no diffing algorithms or reconciliation cycles

    • Faster rendering - direct updates where they're needed

    • Smaller memory footprint - no duplicate DOM trees in memory

    Developer Benefits:

    • Predictable behavior - work directly with web platform APIs

    • Easier debugging - inspect actual DOM elements, not virtual representations

    • Better third-party integration - no compatibility issues with DOM-based libraries

    Modern browsers are incredibly fast at DOM manipulation. Aurelia's intelligent observation and batching system ensures you get maximum performance without the complexity and overhead of virtual DOM solutions.

    hashtag
    Your Learning Journey

    Start Here:

    1. - Build your first app in 15 minutes

    2. - Core concepts explained clearly

    3. - Build reusable UI components

    Build Real Apps: 4. - Master Aurelia's powerful templating 5. - Add navigation to your applications 6. - Handle user input effectively 7. - Test your applications thoroughly 8. - Optimize for production 9. - Complex application patterns

    Need Help? Join our or check for support.

    hashtag
    Join the Aurelia Community

    Ready to contribute or need support? The Aurelia community welcomes developers of all skill levels.

    Get Support:

    • - Real-time chat with the community

    • - Q&A and feature discussions

    • - Technical questions and answers

    Contribute:

    • - How to contribute code and documentation

    • - Report bugs or request features

    • - Help improve these docs

    hashtag
    Ready to Start Building?

    Jump into our and build your first Aurelia application in 15 minutes. You'll be amazed at how intuitive and powerful web development can be with the right tools.

    Extended Tutorial

    Multi-step Project Pulse tutorial that introduces routing, component communication, and real-world app patterns.

    Build a small but realistic app while learning the Aurelia router, component communication, and real-world navigation patterns.

    hashtag
    What you will build

    • A Dashboard and a Projects area

    • Nested routes for Projects (Overview + Activity)

    • A project detail view with route parameters

    • Filterable lists with query params you can share

    • Router events and active navigation styling

    • Multi-locale internationalization (i18n)

    • Form validation with localized error messages

    • Modal dialogs for confirmations and editing

    • Centralized state management

    hashtag
    Prerequisites

    • Completed the

    • Basic TypeScript and HTML

    hashtag
    Steps

    hashtag
    Core (Steps 1–4)

    hashtag
    Advanced routing (Steps 5–6)

    hashtag
    Aurelia packages (Steps 7–10)

    If you are new to Aurelia, start with steps 1–4 to learn routing fundamentals. Steps 5–6 cover advanced routing patterns. Steps 7–10 introduce additional Aurelia packages for building production-ready applications.

    Template Syntax

    Aurelia 2's templating system is the cornerstone of crafting rich, interactive user interfaces for your web applications. It transcends the limitations of static HTML, empowering you to create truly dynamic views that respond intelligently to both application data and user interactions. At its core, Aurelia templating establishes a fluid and intuitive connection between your HTML templates and your application logic written in JavaScript or TypeScript, resulting in a responsive and data-driven UI development experience.

    Forget static HTML pages. Aurelia 2 templates are living, breathing views that actively engage with your application's underlying code. They react in real-time to data modifications and user actions, ensuring your UI is always in sync and providing a seamless user experience. This deep integration not only streamlines your development workflow but also significantly reduces boilerplate, allowing you to build sophisticated UIs with greater clarity and efficiency.

    From the moment you initiate an Aurelia 2 project, you'll find yourself working with templates that are both comfortably familiar in their HTML structure and remarkably powerful in their extended capabilities. Whether you're structuring the layout for a complex component or simply displaying data within your HTML, Aurelia 2's templating syntax is meticulously designed to be both highly expressive and exceptionally developer-friendly, making UI development a truly enjoyable and productive process.

    If you need even finer control, dive into the focused guides linked throughout this section—for example, binding mode behaviors for forcing one-time/one-way flow on demand, or the new for tailoring DOM events.

    hashtag
    Key Features of Aurelia Templating

    Aurelia's templating engine is packed with features designed to enhance your UI development workflow and capabilities:

    • Effortless Two-Way Data Binding: Experience truly seamless synchronization between your application's data model and the rendered view. Aurelia's robust two-way data binding automatically keeps your model and UI in perfect harmony, eliminating manual DOM manipulation and ensuring data consistency with minimal effort.

    • Extendable HTML with Custom Elements and Attributes: Break free from standard HTML limitations by creating your own reusable components and HTML attributes. Encapsulate complex UI logic and behavior into custom elements and attributes, promoting modularity, code reuse, and a more maintainable codebase. This allows you to tailor HTML to the specific needs of your application.

    • Adaptive Dynamic Composition for Flexible UIs: Build truly dynamic and adaptable user interfaces with Aurelia's dynamic composition. Render different components and templates on-the-fly based on your application's state, user interactions, or any dynamic condition. This enables you to create flexible layouts and UI structures that respond intelligently to changing requirements.

    Aurelia 2's templating system is more than just a way to write HTML; it's a comprehensive toolkit for building modern, dynamic web applications with efficiency and elegance. By embracing its features, you'll unlock a more productive and enjoyable UI development experience.

    hashtag
    Learn the Syntax by Topic

    Jump straight into the focused articles that break down each template capability:

    • Text & expression binding – Start with the to master ${ } expressions and formatting tips.

    • Attribute & property binding – Control DOM attributes, classes, and styles with the .

    • Event handling – Wire up DOM interactions plus modifiers using the .

    Local templates (inline templates)

    Learn how to define, use, and optimize local (inline) templates in Aurelia 2 to remove boilerplate and simplify your components.

    hashtag
    Introduction

    Most of the time, when working with templated views in Aurelia, you want to create reusable components. However, there are scenarios where reusability isn’t necessary or might cause unnecessary overhead. Local (inline) templates allow you to define a template as a "one-off" custom element, usable only within the scope of its parent view. This helps reduce boilerplate and fosters clear, localized organization of your code.

    <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>

    By defining <template as-custom-element="person-info">, you create a local component named person-info, which can only be used in this file (my-app.html). It accepts a bindable property person (specified via the <bindable> tag). You can now reuse <person-info> repeatedly in this view without creating a separate file or global custom element.


    hashtag
    Why Use Local Templates?

    Local templates are similar to HTML-Only Custom Elements, with the major difference that local templates are scoped to the file that defines them. They are ideal for:

    • One-off Components: When you need a snippet repeated multiple times in a single view but have no intention of reusing it elsewhere.

    • Reducing Boilerplate: You don’t have to create a new .html and .ts file for every small piece of UI logic.

    • Maintain High Cohesion: Local templates can be optimized for a specific context without worrying about external usage. They can contain deeply nested markup or references to local data without polluting your global component space.

    That said, if you find your local template would be useful across multiple views or components, consider extracting it into a shared component.


    hashtag
    Syntax and Basic Usage

    A local template must be declared with <template as-custom-element="your-element-name">. Inside this

    with.bind (scope binding)

    Change the binding context for a section of a template using Aurelia's built-in with template controller.

    with is a template controller that creates a new binding scope for its content. It’s useful when you want to “zoom in” on an object so you don’t have to repeat a prefix like user. everywhere.

    hashtag
    Basic usage

    <template with.bind="user">
      <h2>${firstName} ${lastName}</h2>
      <p>${email}</p>
    </template>

    The inner template’s binding context becomes user, so firstName resolves as user.firstName, etc.

    You can also put with.bind on a real element:

    hashtag
    “Shape” a small context object

    Sometimes you don’t want to pass the whole object through — just a few values (or renamed values):

    hashtag
    Updates and lifecycle

    • with does not add/remove DOM like if or repeat.

    • When the with value changes, Aurelia re-binds the existing view to the new scope (it does not recreate the view).

    hashtag
    Gotchas

    • with expects an object. If your value can be null/undefined, guard it:

    • Prefer <let> when you only need a couple of local aliases and don’t need to re-scope a whole block.

    Step 2: Routing + nested layouts

    Add primary routes, a Projects layout, and nested child routes.

    In this step you will create the root pages and a Projects layout that hosts child routes.

    hashtag
    1. Create the dashboard

    Create src/pages/dashboard-page.ts:

    Create src/pages/dashboard-page.html:

    Step 4: Detail route + guards

    Add a detail route with parameters and protect it with router guards.

    In this step you will add a parameterized detail route and guard it with canLoad and canUnload.

    hashtag
    1. Add the detail route

    Update src/pages/projects-page.ts to add the detail child route:

    Recipes Overview

    Real-world, production-ready examples showing Aurelia templates in action. Each recipe is a complete, working example demonstrating multiple templating features working together.

    hashtag
    Available Recipes

    hashtag
    E-Commerce & Shopping

    Recipes Overview

    Practical component recipes for building common UI elements in Aurelia

    This section contains practical, real-world examples of building reusable UI components in Aurelia. Each recipe shows you how to create a complete, production-ready component from scratch.

    hashtag
    What You'll Find Here

    These recipes demonstrate:

    Optimized bundle sizes - smart code splitting and async loading
    Future-proof architecture - leverages browser capabilities
    API stability - no breaking changes since 2.0, stable production platform
    Strong TypeScript support from day one
    Quality ecosystem - carefully curated tools and plugins that work seamlessly together
    Better battery life - efficient resource usage on mobile devices
    Standards-aligned - leverage browser capabilities instead of fighting them
    Complete Getting Started Guide
    Essentials
    From React to Aurelia
    From Vue to Aurelia
    From Angular to Aurelia
    Quick Install Guide
    Complete Getting Started Guidechevron-right
    Complete Getting Started Guide
    Essentials
    Componentsarrow-up-right
    Templates & Binding
    Router
    Forms & Validation
    Testing
    Performance
    Advanced Scenariosarrow-up-right
    Discord communityarrow-up-right
    GitHub Discussionsarrow-up-right
    Discord Serverarrow-up-right
    GitHub Discussionsarrow-up-right
    Stack Overflowarrow-up-right
    Contributor Guide
    GitHub Issuesarrow-up-right
    Documentation
    Complete Getting Started Guide
  • Expressive and Intuitive Templating Syntax: Harness the power of Aurelia's rich templating syntax to handle common UI patterns with ease. From iterating over lists of data and conditionally rendering UI elements to effortlessly managing user events, Aurelia's syntax is designed to be both powerful and remarkably intuitive, reducing complexity and boosting productivity.

  • Simplified Data Integration with Expressions and Interpolation: Seamlessly integrate your application data into your templates using Aurelia's straightforward expression syntax. Effortlessly bind data to HTML elements and manipulate attributes directly within your templates using interpolation, making data display and interaction a breeze.

  • Template references – Capture DOM elements, child components, or controllers via the template reference walkthrough.

  • Template variables – Share computed values inside markup with the template variables guide.

  • Async UI flows – Render placeholders or await data using the template promises article.

  • Advanced scenarios – Combine bindings with conditionals, loops, and partials in the recipes collection and forms guide.

  • event modifier catalog
    text interpolation guide
    attribute binding reference
    event binding guide
    Note: Links inside routed components are resolved relative to the current route. Use ../ when you want to navigate to a sibling at the parent level.

    hashtag
    2. Create placeholder child pages

    We will build these pages in later steps, but we need the files now so the routes can resolve.

    Create src/pages/projects-overview-page.ts:

    Create src/pages/projects-overview-page.html:

    Create src/pages/projects-activity-page.ts:

    Create src/pages/projects-activity-page.html:

    hashtag
    3. Create the Projects layout with child routes

    Create src/pages/projects-page.ts:

    Create src/pages/projects-page.html:

    The nested <au-viewport> is where the Overview and Activity pages render.

    hashtag
    4. Wire up the root routes

    Update src/my-app.ts:

    Update src/my-app.html:

    Notice the is-active class when you navigate between Dashboard and Projects.

    Next step: Step 3: Overview page + filters + events

    We keep 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.

    hashtag
    2. Build the detail page

    Create src/pages/project-detail-page.ts:

    Create src/pages/project-detail-page.html:

    Guard recap:

    • canLoad prevents invalid IDs and redirects to projects.

    • canUnload prompts when there is unsaved user input.

    Next step: Step 5: Router events + polish

    • Product Catalog with Search & Filters - Search, filter, sort products with real-time updates

    • Shopping Cart - Add/remove items, update quantities, calculate totals

    hashtag
    Data Display & Tables

    • Data Table with Sorting, Filtering & Pagination - Complete data table with sorting, filtering, pagination, row selection

    hashtag
    UI Components

    • Notification/Toast System - Global notifications with auto-dismiss, multiple types, and queue management

    • Search with Autocomplete - Typeahead search with keyboard navigation and highlighting

    hashtag
    How to Use These Recipes

    Each recipe includes:

    • Complete working code - Copy and paste to get started

    • Feature breakdown - What templating features are being used

    • Variations - Common modifications and extensions

    • Related patterns - Links to similar recipes

    hashtag
    Recipe Template

    Looking to contribute a recipe? Follow this structure:

    hashtag
    Contributing

    Have a great real-world example? We'd love to include it! Submit a PR with your recipe following the template above.

    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)
    Best practices for component architecture
  • Accessibility considerations

  • TypeScript with proper typing

  • Testing patterns for each component

  • Real-world features and edge cases

  • hashtag
    Available Recipes

    hashtag
    UI Components

    • Dropdown Menu: A fully-featured dropdown with keyboard navigation and accessibility

    • Modal Dialog: A flexible modal system with backdrop, animations, and focus management

    • Tabs Componentarrow-up-right: An accessible tab interface with dynamic content

    • : Position-aware tooltips with smart placement

    • : Collapsible content panels with smooth animations

    hashtag
    How to Use These Recipes

    Each recipe includes:

    1. Overview: What the component does and when to use it

    2. Complete Code: TypeScript and HTML for the component

    3. Usage Examples: How to consume the component

    4. Styling: Base CSS to get you started

    5. Testing: How to test the component

    6. Enhancements: Ideas for extending the component

    hashtag
    Prerequisites

    These recipes assume you're familiar with:

    • Aurelia components

    • Bindable properties

    • Template syntax

    • Dependency injection

    hashtag
    Code Standards

    All recipes follow Aurelia 2 best practices:

    • Use resolve() for dependency injection (not decorators)

    • No <template> wrappers in HTML files

    • Named exports for reusable components

    • Proper cleanup in detaching() lifecycle hooks

    • Accessible markup with ARIA attributes

    hashtag
    Contributing

    Have a component recipe you'd like to share? Contributions are welcome! Make sure your recipe includes:

    • Complete, working code

    • Accessibility considerations

    • Usage examples

    • Tests

    • Clear explanations


    Ready to build something? Pick a recipe and start coding!

    Hello World Tutorial
    Step 1: Project setup + app shell
    Step 2: Routing + nested layouts
    Step 3: Overview page + filters + events
    Step 4: Detail route + guards
    Step 5: Router events + polish
    Step 6: Route data + auth roles
    Step 7: Internationalization (i18n)arrow-up-right
    Step 8: Form validation with i18narrow-up-right
    Step 9: Dialogsarrow-up-right
    Step 10: State managementarrow-up-right

    Templates

    Aurelia's templating system provides powerful data binding, event handling, and control flow with intuitive syntax. Templates are HTML files enhanced with binding expressions and template controls.

    hashtag
    Data Binding

    hashtag
    Text Interpolation

    Display dynamic content using string interpolation:

    hashtag
    Property Binding

    Bind to element properties and attributes:

    hashtag
    Two-way Binding

    Create two-way data flow with .bind:

    As you type, both the input and paragraph update automatically.

    hashtag
    Event Handling

    Handle user interactions with .trigger:

    hashtag
    Conditional Rendering

    Show or hide content based on conditions:

    hashtag
    List Rendering

    Display dynamic lists with repeat.for:

    hashtag
    Template References

    Access DOM elements directly:

    hashtag
    Template Variables

    Create local variables within templates:

    hashtag
    What's Next

    • Explore the complete

    • Learn about for data transformation

    • Discover for reusable behaviors

    CSS classes and styling

    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.

    Aurelia makes it easy to modify an element inline class list and styles. You can work with not only strings but also objects to manipulate elements.

    hashtag
    Binding HTML Classes

    The class binding allows you to bind one or more classes to an element and its native class attribute.

    hashtag
    Binding to a single class

    Adding or removing a single class value from an element can be done using the .class binding. By prefixing the .class binding with the name of the class you want to display conditionally selected.class="myBool" you can add a selected class to an element. The value you pass into this binding is a boolean value (either true or false), if it is true the class will be added; otherwise, it will be removed.

    Inside of your view model, you would specify isSelected as a property and depending on the value, the class would be added or removed.

    Here is a working example of a boolean value being toggled using .class bindings.

    hashtag
    Binding to multiple classes

    In addition to binding single classes conditionally, you can also bind multiple classes based on a single boolean expression using a comma-separated list in the .class binding syntax. This allows you to toggle a set of related classes together.

    In this example, if the hasError property in your view model is truthy, all four classes (alert, alert-danger, fade-in, and bold-text) will be added to the div element. If hasError is falsy, all four classes will be removed. Important Note: When using the comma-separated syntax for multiple classes, ensure there are no spaces around the commas. The parser expects a direct list of class names separated only by commas (e.g., class1,class2,class3).

    Besides the .class syntax, there are other ways to achieve dynamic class binding, especially when dealing with complex logic or generating class strings:

    Syntax
    Input Type
    Example

    Once you have your CSS imported and ready to use in your components, there might be instances where you want to dynamically bind to the style attribute on an element (think setting dynamic widths or backgrounds).

    hashtag
    Binding Inline Styles

    hashtag
    Binding to a single style

    You can dynamically add a CSS style value to an element using the .style binding in Aurelia.

    Inside of your view model, you would specify bg as a string value on your class.

    Here is a working example of a style binding setting the background colour to blue:

    hashtag
    Binding to multiple styles

    To bind to one or more CSS style properties you can either use a string containing your style values (including dynamic values) or an object containing styles.

    hashtag
    Style binding using strings

    This is what a style string looks like, notice the interpolation here? It almost resembles just a plain native style attribute, with exception of the interpolation for certain values. Notice how you can also mix normal styles with interpolation as well?

    You can also bind a string from your view model to the style property instead of inline string assignment by using style.bind="myString" where myString is a string of styles inside of your view model.

    hashtag
    Style binding using objects

    Styles can be passed into an element by binding to the styles property and using .bind to pass in an object of style properties. We can rewrite the above example to use style objects.

    From a styling perspective, both examples above do the same thing. However, we are passing in an object and binding it to the style property instead of a string.

    Overview

    A guided tour of Aurelia fundamentals; start here before diving into the deeper topic guides.

    Use this section as your orientation to Aurelia. Each topic builds on the last, moving from first render through composition patterns, state management, and the services that power real applications.

    hashtag
    How to Navigate

    • Start with the introductions to see Aurelia's templating flavor and ergonomics in action.

    • Pick the boot path (full app vs. enhancement) that matches your project.

    • Add capabilities incrementally: routing, composition, observation, without waiting for a rewrite.

    • Dip into advanced topics last, once the fundamentals are comfortable.

    hashtag
    Topic Map

    Theme
    Read this first
    Follow with

    hashtag
    Suggested Learning Path

    1. Skim the templating introductions to get comfortable with Aurelia's binding language.

    2. Choose your startup approach: full SPA bootstrap or incremental enhancement.

    3. Layer on routing once you have more than one screen.

    Each article calls out prerequisites and links forward so you can keep learning without losing the big picture.

    Class & style binding

    Bind CSS classes and inline styles in Aurelia templates using expressive syntax.

    Class and style bindings in Aurelia allow you to bind to CSS properties and add one or more classes to your HTML elements inside of your views.

    hashtag
    Binding to the class attribute

    The class binding allows you to bind one or more classes to an element and its native class attribute.

    hashtag
    Binding to a single class

    Adding or removing a single class value from an element can be done using the .class binding. By prefixing the .class binding with the name of the class you want to conditionally display, for example, selected.class="myBool" you can add a selected class to an element. The value you pass into this binding is a boolean value (either true or false), if it is true the class will be added, otherwise, it will be removed.

    Inside of your view model, you would specify isSelected as a property and depending on the value, the class would be added or removed.

    Here is a working example of a boolean value being toggled using .class bindings.

    hashtag
    Binding to multiple classes

    Unlike singular class binding, you cannot use the .class binding syntax to conditionally bind multiple CSS classes. However, there is a multitude of different ways in which this can be achieved.

    Syntax
    Input Type
    Example

    hashtag
    Binding to the style attribute

    Dynamically set CSS styles on elements in your view templates.

    hashtag
    Binding to a single style

    You can dynamically add a CSS style value to an element using the .style binding in Aurelia.

    Inside of your view model, you would specify bg as a string value on your class.

    Here is a working example of a style binding setting the background color to blue:

    hashtag
    Binding to multiple styles

    To bind to one or more CSS style properties you can either use a string containing your style values (including dynamic values) or an object containing styles.

    hashtag
    Style binding using strings

    This is what a style string looks like, notice the interpolation here? It almost resembles just a plain native style attribute, with exception of the interpolation for certain values. Notice how you can also mix normal styles with interpolation as well?

    You can also bind a string from your view model to the style property instead of inline string assignment by using style.bind="myString" where myString is a string of styles inside of your view model.

    hashtag
    Style binding using objects

    Styles can be passed into an element by binding to the styles property and using .bind to pass in an object of style properties. We can rewrite the above example to use style objects.

    From a styling perspective, both examples above do the same thing. However, we are passing in an object and binding it to the style property instead of a string.

    Step 5: Router events + polish

    Add router event listeners and polish the navigation experience.

    In this step you will listen to router events and surface navigation state in the UI.

    hashtag
    1. Listen to router events in the Projects layout

    Update src/pages/projects-page.ts:

    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();
      }
    }

    Update src/pages/projects-page.html:

    hashtag
    2. Recap the routing features used

    You now have:

    • Root routes and child routes

    • Active navigation styling via activeClass

    • Query params synced from the UI

    If you want, expand this app by adding child routes under projects/detail/:id, or persist notes to storage.

    Next step:

    Quick Install Guide

    Get Aurelia running in under 5 minutes with this quick installation guide.

    Get Aurelia up and running in 5 minutes or less.

    hashtag
    Prerequisites

    • (latest version recommended)

    Attribute mapping

    Learn about binding values to attributes of DOM elements and how to extend the attribute mapping with great ease.

    Attribute mapping is Aurelia's way of keeping template syntax concise. After an attribute pattern parses the attribute name but before a binding command emits instructions, the mapper answers two questions:

    1. Which DOM property does this attribute target?

    2. Should .bind implicitly behave like .two-way for this attribute?

    Spread operators

    Aurelia supports two different spread features in templates:

    1. Bindables spreading: bind multiple properties from an object onto a custom element’s bindable properties.

    2. Attribute transferring: forward captured attributes/bindings from a custom element usage to an element inside its template.

    This page focuses on the template syntax and common gotchas. For the deeper conceptual guide, see:

    Tooltiparrow-up-right
    Accordion
    template syntax overview
    value converters
    custom attributes
    Programmatic navigation via IRouter.load
  • Router events via IRouterEvents

  • Guards (canLoad and canUnload) for real-world UX

  • Step 6: Route data + auth roles
    This is the mechanism that lets you write <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.

    hashtag
    When to extend IAttrMapper

    Reach for the mapper when:

    • Bridging custom elements – Third-party components often expose camelCase properties such as valueAsDate or formNoValidate.

    • Designing DSLs – Attributes like data-track or foo-bar need to land on specific DOM properties regardless of casing.

    • Improving authoring ergonomics – Upgrading progress.bind to two-way on slider-like controls keeps templates readable.

    If you need to invent new attribute syntaxes ([(value)], @click, etc.), start with attribute patterns. If you need to observe DOM properties, follow up with the node observer locator.

    hashtag
    How the mapper decides

    When Aurelia encounters an attribute that does not belong to a custom element bindable, it walks through the mapper logic:

    1. Check tag-specific mappings registered via useMapping.

    2. Fall back to global mappings from useGlobalMapping.

    3. If no mapping exists, camelCase the attribute name.

    4. If the binding command is bind, ask each predicate registered via useTwoWay whether the attribute should become two-way.

    Your extensions only run for attributes that are not already handled by custom element bindables, so you can layer mappings without unintentionally overriding component contracts.

    hashtag
    Registering mappings during startup

    Use AppTask.creating to register mappings before Aurelia instantiates the root component:

    Keys inside useMapping must match the element's tagName (uppercase). The destination values must match the actual DOM property names exactly (formNoValidate, not formnovalidate).

    With the mapping above in place, templates stay clean:

    hashtag
    Enabling implicit two-way bindings

    Some controls should default to two-way binding even when authors write .bind. Use useTwoWay to register a predicate (element, attrName) => boolean:

    Predicates receive the live element, so you can inspect classes, attributes, or even dataset values before opting into two-way. Keep the logic lightweight—these predicates run for every *.bind attribute Aurelia encounters.

    hashtag
    Troubleshooting and best practices

    • Uppercase tag names – Browsers expose element.tagName in uppercase; use the same casing in useMapping.

    • Avoid duplicates – Registering the same tag/attribute combination twice throws. Remove or consolidate old mappings before adding new ones.

    • Destination accuracy – Mistyped destination properties silently fall back to camelCase conversion. Inspect the element in devtools and read Object.keys(element) if unsure.

    • Predicate order matters – useTwoWay predicates run in registration order. Put the most specific check first.

    • Verify manually – Toggle the DOM property in devtools. If the UI updates but Aurelia does not, revisit the observer configuration. If neither updates, revisit the mapping.

    • Pair with observers – Mapping alone does not teach Aurelia how to observe custom properties. Follow up with INodeObserverLocator.useConfig so bindings know which events to listen to.

    With the mapper tailored to your components, you can keep templates expressive while relying on the full power of Aurelia's binding system.

    Bindable Properties (Bindables spreading, Attributes Transferring)

  • Attribute Transferring


  • hashtag
    1) Bindables spreading

    Bindables spreading is for custom element usage. It creates one-way (to-view) bindings from an object’s properties to matching bindable properties on the custom element.

    hashtag
    Syntax options

    hashtag
    Example

    Only keys that match bindable property names (name, email, avatarUrl) create bindings. Extra keys are ignored.

    hashtag
    Important gotchas

    • Keys are property names, not attribute names: firstName matches @bindable firstName, but first-name does not.

    • Shorthand is HTML-case-sensitive in a bad way: HTML lowercases attribute names, so ...firstName becomes ...firstname and won’t match your view-model property. Prefer ...$bindables="firstName" when case matters.

    • No spaces in shorthand: ...a + b becomes multiple attributes (...a, +, b) and will not work. Use ...$bindables="a + b".

    • Direction is always one-way (to-view): bindables spreading does not support two-way/from-view. Binding behaviors can’t change direction, but can still affect evaluation (e.g. ...$bindables="user & debounce:200").

    • Bindings are based on existing keys: if the object did not have a key at the time it was evaluated, no binding is created for that property. To “add” bindings, assign a new object.

    hashtag
    Avoid double-binding the same property

    You can technically combine spread syntax with explicit bindings, but it creates multiple bindings targeting the same property and can be confusing.

    Prefer one approach, or ensure the intent is obvious and well-documented. If you do mix them, the last declared binding typically “wins” at update time.


    hashtag
    2) Attribute transferring (...$attrs)

    Attribute transferring is for inside a component template: it forwards “captured” attributes and bindings from the component’s usage to an element inside the component.

    hashtag
    Minimal example

    Enable capturing on the custom element definition:

    Use it like a normal input:

    Everything that is captured from <form-input ...> will be applied to the inner <input ...$attrs>.

    hashtag
    What gets captured?

    When capture: true is enabled, Aurelia captures (for later spreading) everything except:

    • custom element bindables (those are handled as bindables, not captured)

    • template controllers (like if, repeat.for, with, portal, etc.)

    You can also supply a capture filter function if you only want to capture certain attributes.

    hashtag
    Restrictions

    • You cannot transfer template controllers via ...$attrs (you’ll get a compile error).

    • Don’t overuse deep “pass-through” chains; one or two layers is usually plenty.

    <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>

    Router fundamentals, navigation, lifecycle, and advanced guides for each router package

    Composition patterns

    ,

    State & observation

    ,

    Services & runtime hooks

    , , ,

    Deep dives

    None

    Explore composition and state tools to keep components small and expressive.
  • Wire in services and lifecycle hooks as the app grows in complexity.

  • Only then crack open the framework internals for architecture digging or advanced debugging.

  • Templates & syntax

    Built-in template features

    Class & style binding, Attribute transferring

    Bootstrapping

    App configuration & startup

    Enhance

    Navigation

    A code editor of your choice

    hashtag
    Option 1: Try Aurelia Instantly (No Setup Required)

    Want to try Aurelia immediately? Copy this into an HTML file and open it in your browser:

    circle-info

    No installation required! This uses Aurelia directly from a CDN. Perfect for experimentation or simple projects. For a more complete example, see the realworld-vanilla examplearrow-up-right which demonstrates a full application with routing.

    hashtag
    Option 2: Create Your App

    Aurelia uses the Makesarrow-up-right scaffolding tool. No global installs required.

    When prompted:

    • Project name: Enter your project name

    • Setup: Choose TypeScript (recommended) or ESNext

    • Install dependencies: Select "Yes"

    circle-info

    Why TypeScript? Get intellisense, type safety, and better tooling support out of the box.

    hashtag
    Run Your App

    Navigate to your project and start the development server:

    Your browser will automatically open to http://localhost:8080 showing your new Aurelia app.

    hashtag
    Verify Everything Works

    You should see "Hello World!" displayed in your browser. The development server watches for changes and auto-reloads.

    hashtag
    What's Next?

    • New to Aurelia? Try our Hello World Tutorial for a hands-on introduction

    • Ready for more? Explore our developer guidesarrow-up-right and tutorialsarrow-up-right

    • Need help? Check out troubleshooting

    hashtag
    Recommended Reading

    Want deeper context after the quick start? These guides build on the concepts introduced here:

    • Component basics – Understand how views and view-models pair up plus when to use imports vs. global registration in Component essentials.

    • Project structure & conventions – See how the scaffolded files fit together in the Complete guide's project structure section.

    • Template syntax & binding – Explore binding commands, loops, and conditionals in the template syntax overview.

    • Dependency injection – Learn how Aurelia's DI container works and when to call resolve() in the .

    • Routing – When you need multiple pages, follow the to add navigation.

    hashtag
    Core Concepts (Optional Reading)

    Aurelia is built on familiar web technologies with a few key concepts:

    • Components: Made of view-models (.ts/.js) and views (.html)

    • Conventions: File names and structure follow predictable patterns

    • Dependency Injection: Built-in system for managing services and dependencies

    • Enhanced HTML: Templates use familiar HTML with powerful binding syntax

    These concepts become clearer as you build with Aurelia. Start with the tutorial above to see them in action!

    Node.jsarrow-up-right
    <!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 aurelia
    cd 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>

    class.bind="someString"

    string

    'col-md-4 bg-${bgColor}'

    class="${someString}"

    string

    col-md-4 ${someString}

    <p background.style="bg">My background is blue</p>
    my-app.ts
    export class MyApp {
      private backgroundColor = 'black';
      private textColor = '#FFF';
    }
    my-app.html
    <p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>
    my-app.ts
    export class MyApp {
      private styleObject = {
        background: 'black',
        color: '#FFF'
      };
    }
    my-app.html
    <p style.bind="styleObject">Hello there</p>
    <p selected.class="isSelected">I am selected (I think)</p>

    class.bind="someString"

    string

    'col-md-4 bg-${bgColor}'

    class="${someString}"

    string

    col-md-4 ${someString}

    <p background.style="bg">My background is blue</p>
    my-app.ts
    export class MyApp {
      private backgroundColor = 'black';
      private textColor = '#FFF';
    }
    my-app.html
    <p style="color: ${textColor}; font-weight: bold; background: ${backgroundColor};">Hello there</p>
    
    my-app.ts
    export class MyApp {
      private styleObject = {
        background: 'black',
        color: '#FFF'
      };
    }
    my-app.html
    <p style.bind="styleObject">Hello there</p>
    

    Template variables

    Aurelia 2 allows you to manage variables directly within your view templates: the <let> custom element. This element allows you to declare and initialize variables inline in your HTML, making your templates more dynamic and readable. <let> is incredibly versatile, supporting a range of value assignments, from simple strings and interpolation to complex expressions and bindings to your view model. This capability significantly enhances template flexibility and reduces the need for excessive view model code for simple template-specific logic.

    hashtag
    Declaring Template Variables with <let>

    The <let> element provides a straightforward syntax for declaring variables directly within your templates. The basic structure is as follows:

    • <let>: The custom element tag that signals the declaration of a template variable.

    • variable-name: The name you choose for your template variable. In templates, you will reference this variable name in its camelCase form (e.g., variableName).

    • "variable value"

    hashtag
    Basic String Assignment

    You can assign simple string literals to <let> variables:

    To display the value of this variable in your template, use interpolation with the camelCase version of the variable name:

    This will render:

    hashtag
    Binding Expressions for Dynamic Values

    <let> variables are not limited to static strings. You can use binding expressions to assign dynamic values that are calculated or updated based on your view model or other template logic.

    Example: Simple Mathematical Expression

    Now, you can display the result of this calculation:

    This will output:

    Example: Binding to View Model Properties

    You can bind a <let> variable to properties defined in your view model, making template variables reactive to changes in your data:

    In this example, both ${userName} interpolations will display "John Doe". If you update the userName property in your view model, both interpolations will dynamically reflect the change.

    Example: Using Template Expressions

    <let> variables can also be assigned values derived from template expressions, including function calls, ternary operators, and more:

    Here, isEvening will be a boolean value based on the current hour, and timeOfDayMessage will be dynamically set to either "Good evening" or "Good day" based on the value of isEvening.

    hashtag
    Scoping of Template Variables

    <let> variables are scoped to the template in which they are declared. This means a variable declared with <let> is only accessible within the template block where it's defined. This scoping helps prevent naming conflicts and keeps your templates organized and predictable.

    Example: Scoped Variables in repeat.for

    When using <let> within a repeat.for loop, each iteration of the loop will have its own instance of the <let> variable, ensuring that variables are correctly associated with each repeated item.

    In this example, itemIndex is scoped to each <li> element within the repeat.for loop, correctly displaying the index for each item in the list.

    hashtag
    Practical Use Cases for <let>

    <let> is incredibly useful in various template scenarios. Here are a few common use cases:

    hashtag
    1. Simplifying Complex Expressions

    When you have complex expressions that are used multiple times within a template, you can use <let> to assign the result of the expression to a variable, improving readability and maintainability.

    Before using <let>:

    After using <let>:

    Using <let subtotal.bind="quantity * price"> makes the template cleaner and easier to understand, especially if the calculation is more complex.

    hashtag
    2. Conditional Rendering Logic

    You can use <let> in conjunction with conditional attributes like if.bind or else to manage template variables based on conditions.

    Here, showDetails is used to control both the button text and the visibility of the details section, simplifying the conditional logic within the template.

    hashtag
    3. Data Transformation within Templates

    You can perform simple data transformations directly within your templates using <let>, although for more complex transformations, value converters are generally recommended.

    Example: Formatting a Date

    This example formats the current date using toLocaleDateString() and stores it in formattedDate for display.

    hashtag
    4. Creating Reusable Template Snippets

    While not its primary purpose, <let> can indirectly contribute to creating reusable template snippets by encapsulating logic and variables within a specific section of your template. Combined with custom elements or template parts, <let> helps in modularizing your view templates.

    hashtag
    Considerations when Using <let>

    • Keep it Simple: While <let> is powerful, it's best used for template-specific variables and simple logic. For complex data manipulation or business logic, keep that in your view model.

    • Readability: Use descriptive variable names for <let> to maintain template readability.

    • Scoping

    Built-in template features

    Use Aurelia's built-in template commands such as if, show, repeat, and switch to control markup dynamically.

    This topic demonstrates how to work with Aurelia's built-in template commands which allow you to conditionally show and hide content, loop over collections of content, conditional rendering using switch/case syntax in your views and a trove of other built-in template features.

    hashtag
    Adding or removing an element using if.bind

    You can add or remove an element by specifying an if.bind on an element and passing in a true or false value.

    When if.bind is passed false Aurelia will remove the element all of its children from the view. When an element is removed, if it is a custom element or has any events associated with it, they will be cleaned up, thus freeing up memory and other resources they were using.

    In the following example, we are passing a value called isLoading which is populated whenever something is loading from the server. We will use it to show a loading message in our view.

    When isLoading is a truthy value, the element will be displayed and added to the DOM. When isLoading is falsy, the element will be removed from the DOM, disposing of any events or child components inside of it.

    hashtag
    Showing or hiding an element using show.bind

    You can conditionally show or hide an element by specifying a show.bind and passing in a true or false value.

    When show.bind is passed false the element will be hidden, but unlike if.bind it will not be removed from the DOM. Any resources, events or bindings will remain. It's the equivalent of display: none; in CSS, the element is hidden, but not removed.

    In the following example, we are passing a value called isLoading which is populated whenever something is loading from the server. We will use it to show a loading message in our view.

    When isLoading is a truthy value, the element will be visible. When isLoading is falsy, the element will be hidden, but remain in the view.

    hashtag
    Conditionally add or remove elements using switch.bind

    In Javascript we have the ability to use switch/case statements which act as neater if statements. We can use switch.bind to achieve the same thing within our templates.

    The switch.bind controller will watch the bound value, which in our case is selectedAction and when it changes, match it against our case values. It is important to note that this will add and remove elements from the DOM like the if.bind does.

    hashtag
    Using promises in templates with promise.bind

    When working with promises in Aurelia, previously in version 1 you had to resolve them in your view model and then pass the values to your view templates. It worked, but it meant you had to write code to handle those promise requests. In Aurelia 2 we can work with promises directly inside of our templates.

    The promise.bind template controller allows you to use then, pending and catch in your views removing unnecessary boilerplate.

    In the following example, notice how we have a parent div with the promise.bind binding and then a method called fetchAdvice? Followed by other attributes inside then and catch which handle both the resolved value as well as any errors.

    Ignore the i variable being incremented, this is only there to make Aurelia fire off a call to our fetchAdvice method as it sees the parameter value has changed.

    hashtag
    Looping over collections with repeat.for

    circle-check

    To see live examples of repeat.for being used, visit the .

    You can use the repeat.for binding to iterate over collections of data in your templates. Think of repeat.for as a for loop, it can iterate arrays, maps and sets.

    Breaking this intuitive syntax down, it works like this:

    • Loop over every item in the items array

    • Store each iterative value in the local variable item on the left hand side

    • For each iteration, make the current item available

    If you were to write the above in Javascript form, it would look like this:

    hashtag
    Creating ranges with repeat.for

    The repeat.for functionality doesn't just allow you to work with collections, it can be used to generate ranges.

    In the following example, we generate a range of numbers to 10. We subtract the value from the index inside to create a reverse countdown.

    hashtag
    Getting the index (and other contextual properties) inside of repeat.for

    Aurelia's binding engine makes several special properties available to you in your binding expressions. Some properties are available everywhere, while others are only available in a particular context. Below is a brief summary of the available contextual properties within repeats.

    • $index - In a repeat template, the index of the item in the collection.

    • $first - In a repeat template, is true if the item is the first item in the array.

    • $last

    Inside of the repeat.for these can be accessed. In the following example we display the current index value.

    Extending binding language

    The Aurelia template compiler is powerful and developer-friendly, allowing you extend its binding language with great ease.

    The Aurelia binding language provides commands like .bind, .one-way, .trigger, .for, .class etc. These commands are used in the view to express the intent of the binding, or in other words, to build binding instructions.

    Although the out-of-box binding language is sufficient for most use cases, Aurelia also provides a way to extend the binding language so that developers can create their own incredible stuff when needed.

    In this article, we will build an example to demonstrate how to introduce your own binding commands using the @bindingCommand decorator from the template compiler.

    hashtag
    Binding command

    Before jumping directly into the example, let's first understand what a binding command is. In a nutshell, a binding command is a piece of code used to register "keywords" in the binding language and provide a way to build binding instructions from that.

    To understand it better, we start our discussion with the template compiler. The template compiler is responsible for parsing templates and, among all, creating attribute syntaxes. This is where the come into play. Depending on how you define your attribute patterns, the attribute syntaxes will be created with or without a binding command name, such as bind, one-way, trigger, for, class, etc. The template compiler then instantiates binding commands for the attribute syntaxes with a binding command name. Later, binding instructions are built from these binding commands, which are "rendered" by renderers. Depending on the binding instructions, the " rendering " process can differ. For this article, the rendering process details are unimportant, so we will skip it.

    hashtag
    Creating a custom binding command

    To create a binding command, decorate a class with @bindingCommand and implement the following interface:

    When the template compiler encounters an attribute, it first lets custom elements or attributes claim it. Only when no bindable handles the attribute does it look up a binding command whose name matches the parsed instruction. Setting ignoreAttr = true tells the compiler that your command consumes the attribute as-is and it should not keep probing for other handlers. Built-in commands like .two-way keep this value false, whereas specialized commands such as .attr set it to true so they can short-circuit the remaining checks.

    The more interesting part of the interface is the build method. The template compiler calls this method to build binding instructions. The info parameter contains information about the element, the attribute name, the bindable definition (if present), and the custom element/attribute definition (if present). The parser parameter is used to parse the attribute value into an expression. The mapper parameter of is used to determine the binding mode, the target property name, etc. (for more information, refer to the ). In short, here comes your logic to convert the attribute information into a binding instruction.

    For our example, we want to create a binding command that can trigger a handler when custom events such as bs.foo.bar, bs.fizz.bizz etc. are fired, and we want the following syntax:

    instead of

    We first create a class that implements the BindingCommandInstance interface to do that.

    Note that from the build method, we are creating a ListenerBindingInstruction with bs. prefixed to the event name used in the markup. Thus, we are saying that the handler should be invoked when a bs.* event is raised.

    To register the custom binding command, it needs to be registered with the dependency injection container.

    hashtag
    Registering the custom binding command

    Because @bindingCommand wires up the resource metadata, registering the class is all the compiler needs to find it.

    hashtag
    Why ignoreAttr = true?

    Setting ignoreAttr = true tells the compiler that this binding command fully manages the attribute in the view. Without this flag, Aurelia might attempt to interpret the same attribute as a custom attribute or a normal bindable property. This can lead to conflicts or warnings if you reuse attribute names already in use by other features.

    hashtag
    Debugging custom binding commands

    If your command doesn't behave as expected:

    • Make sure you've registered it before Aurelia starts (see the main.ts snippet above).

    • Double-check that the command name (e.g., 'bs') matches in both the @bindingCommand('bs') decorator and your view markup (foo.bar.bs="...").

    And that's it! We have created our own binding command and registered it. This means the following syntax will work:

    hashtag
    Live example

    This binding command can be seen in action below.

    Note that the example defines a custom attribute pattern to support foo.bar.fizz.bs="ev => handle(ev)" syntax.

    Aurelia for New Developers

    New to Javascript, Node.js and front-end development in general? Don't worry, we got you.

    Welcome to the magical world of Javascript development. This guide is for any newcomer to front-end development who isn't that experienced with modern tooling or Javascript frameworks.

    hashtag
    Getting started

    For the purposes of this tutorial and as a general rule for any modern framework like Aurelia, you will be using a terminal of some sort. On Windows, this can be the Command Prompt or Powershell. On macOS, it'll be Terminal (or any other Terminal alternative), the same thing with Linux.

    To work with Aurelia, you will need to install Node.js. If you are new to Node.js, it is used by almost every tool in the front-end ecosystem now, from Webpack to other niche bundlers and tools. It underpins the front-end ecosystem.

    The easiest way to install Node.js is from the official website . Download the installer for your operating system and then follow the prompts.

    hashtag
    Download a code editor

    To write code, you need an editor that will help you. The most popular choice for Javascript development is . It is a completely free and open-source code editor made by Microsoft, which has great support for Aurelia applications and Node.js.

    hashtag
    Create a new Aurelia project

    We will be following the instructions in the to bootstrap a new Aurelia application. After installing Node.js, that's it. You don't need to install anything else to create a new Aurelia application, here's how we do it.

    Open up a Terminal/Command Prompt window and run the following:

    You are going to be presented with a few options when you run this command. Don't worry, we'll go through each screen step by step.

    hashtag
    Step 1. Name your project

    You will be asked to enter a name for your project, this can be anything you want. If you can't think of a name just enter my-app and then hit enter.

    hashtag
    Step 2. Choose your options

    In step 2 you will be presented with three options.

    • Option one: "Default ESNext Aurelia 2 App" this is a basic Aurelia 2 Javascript application using Babel for transpiling and Webpack for the bundler.

    • Option two: "Default Typescript Aurelia 2 App" this is a basic Aurelia 2 TypeScript application with Webpack for the bundler.

    • Option three: "Custom Aurelia 2 App" no defaults, you choose everything.

    In this guide, we are going to go with the most straightforward option, option #1.

    hashtag
    Step 3. Install the dependencies

    You are going to be asked if you want to install the Npm dependencies and the answer is yes. For this guide we are using Npm, so select option #2.

    Depending on your internet connection speed, this can take a while.

    hashtag
    Step 4. Run the sample app

    After the installation is finished you should see a little block of text with the heading, "Get Started" follow the instructions. Firstly, cd my-app to go into the directory where we installed our app. Then run npm start to run our example app.

    Your web browser should open automatically and point to http://localhost:9000

    Any changes you make to the files in the src directory of your app will cause the dev server to refresh the page with your new changes. Edit my-app.html and save it to see the browser update. Cool!

    hashtag
    Building your app

    In the last section we created a new application and ran the development server, but in the "real world" you will build and deploy your site for production.

    Run the Npm build command by running the following in your Terminal or Command Prompt window:

    This will build your application for production and create a new folder called dist.

    hashtag
    Keep Learning

    Once the CLI basics feel comfortable, continue with these focused guides:

    • Build something interactive – Follow the for a guided, hands-on walkthrough.

    • See the bigger picture – Work through the to assemble a full-featured sample app.

    • Understand components – Dive into the to learn how Aurelia pairs view-models with HTML.

    File Uploads

    Learn how to handle file inputs and uploads in Aurelia forms.

    hashtag
    Basic File Input

    hashtag
    Single File Upload

    Routing

    Understand the @aurelia/router package, its core concepts, and how to navigate the rest of the routing documentation.

    Aurelia's primary router gives you a declarative, component-first navigation system with strong type safety, multi-viewport layouts, and deep integration with dependency injection. If you have used Angular's router or the classic Aurelia 1 router, the mental model will feel familiar: define a route table, map URLs to components, nest layouts, guard navigation, lazy-load feature areas, and respond to lifecycle events. The Aurelia router stays HTML-friendly and convention-driven, letting you compose navigation without wrapper modules or excessive configuration.

    Still deciding between routers? Start with .

    hashtag
    Highlights

    Globals

    Learn how Aurelia 2 handles global variables in templates, the built-in list of accessible globals, and when to use them effectively.

    By design, Aurelia templates limit direct access to global variables like window or document for security and maintainability reasons. However, Aurelia recognizes that some JavaScript globals are frequently needed—like Math, JSON, or Array—and therefore provides a predefined list of global objects that can be safely accessed in template expressions.


    DI overview
    router getting started guide
    Choosing a routerarrow-up-right
    Template controllers
    Dynamic composition
    Portalling elements
    Understanding the binding system
    Observation overview
    Watching data
    Dependency injection primer
    App tasks
    Task queue
    Event aggregator
    Logging
    Framework internals
    : The initial value assigned to the variable. This can be a string literal, an interpolation expression, a binding expression, or any valid JavaScript expression that Aurelia can evaluate within the template context.
    : Be mindful of the scope of
    <let>
    variables. They are limited to the template in which they are declared.
  • Alternatives: For complex data transformations or reusable formatting logic, consider using Aurelia's value converters, which are designed for these purposes and promote better separation of concerns.

  • - In a repeat template, is
    true
    if the item is the last item in the array.
  • $middle - In a repeat template, is true if the item is neither first nor last.

  • $even - In a repeat template, is true if the item has an even numbered index.

  • $odd - In a repeat template, is true if the item has an odd numbered index.

  • $length - In a repeat template, this indicates the length of the collection.

  • $previous - In a repeat template, returns the previous item in the collection (or undefined for the first item).

  • $parent - Explicitly accesses the outer scope from within a repeat template. You may need this when a property on the current scope masks a property on the outer scope. Note that this property is chainable, e.g. $parent.$parent.foo is supported.

  • repeat.for examples

    Master templates & binding – Explore the templates overview plus the template syntax deep dive.

  • Share data across components – Learn how and when to use the DI container in the dependency injection overview.

  • Troubleshoot faster – Bookmark debugging and troubleshooting for common fixes and CLI tips.

  • herearrow-up-right
    Visual Studio Codearrow-up-right
    Quick install guide
    Hello World tutorial
    Complete getting started guide
    component essentials
    hashtag
    File Preview

    hashtag
    Validation

    hashtag
    Progress Tracking

    hashtag
    Best Practices

    1. Validate on both client and server - Always verify file types and sizes server-side

    2. Clean up object URLs - Revoke object URLs in detaching() to prevent memory leaks

    3. Show progress for large files - Use XMLHttpRequest for progress tracking

    4. Provide clear feedback - Show file names, sizes, and upload status

    5. Handle errors gracefully - Display meaningful error messages

    hashtag
    Security Considerations

    • Validate file types server-side (don't trust accept attribute)

    • Check file sizes to prevent DoS attacks

    • Scan uploaded files for malware

    • Store files outside web root

    • Use unique filenames to prevent overwrites

    • Implement rate limiting

    hashtag
    Related

    • Form Basics

    • Form Submission

    • Form Examplesarrow-up-right

    hashtag
    Why Limit Global Access?
    • Security: Restricting direct access to browser globals reduces the risk of accidental or malicious operations on sensitive objects.

    • Maintainability: Encourages developers to keep logic in their view models, improving code clarity.

    • Performance: Minimizes the amount of unnecessary logic in templates, preventing overuse of global operations in tight rendering loops.

    Despite these constraints, Aurelia acknowledges the utility of common global constructors and functions. Below is the canonical list accessible within Aurelia 2 templates without additional configuration:

    • Infinity

    • NaN

    • isFinite

    • isNaN

    • parseFloat

    • parseInt

    • decodeURI

    • decodeURIComponent

    • encodeURI

    • encodeURIComponent

    • Array

    • BigInt

    • Boolean

    • Date

    • Map

    • Number

    • Object

    • RegExp

    • Set

    • String

    • JSON

    • Math

    • Intl


    hashtag
    Example Usages of Built-In Globals

    Below are illustrative examples showing how to use these built-in globals in Aurelia templates. The syntax is identical to standard JavaScript, but you simply call them within Aurelia’s binding expressions.

    hashtag
    1. Working with JSON

    Serialize an object for debugging or quick display:

    hashtag
    2. Mathematical Operations with Math

    Perform simple or complex calculations:

    hashtag
    3. Conditional Rendering with isNaN

    Use global numeric checks to conditionally display elements:

    hashtag
    4. Regular Expressions with RegExp

    Construct inline regular expressions for quick validation:

    hashtag
    5. Dynamic Property Access with Object

    Use Object methods for reflection or retrieval:

    hashtag
    6. Set Operations with Set

    De-duplicate arrays or combine sets inline:

    hashtag
    7. Encoding & Decoding URLs

    Leverage encodeURI / decodeURI for safe link construction:

    hashtag
    8. Number Formatting with Intl.NumberFormat

    Localize numbers, currency, or dates easily:

    hashtag
    9. Complex Array Manipulations

    Filter, map, and transform arrays:


    hashtag
    Best Practices and Considerations

    1. Use Sparingly

      • Keep business logic in your view models, not in templates. Inline calls to complex global functions (e.g., JSON.stringify on large data) can degrade performance and reduce readability.

    2. Security

      • Even though Aurelia limits global access, treat any data you process via global functions (e.g., decodeURI) with caution to prevent potential XSS attacks or other vulnerabilities.

    3. Performance

      • Template expressions run on each re-render. If you repeatedly perform expensive operations (like JSON.stringify on large objects), consider handling them in the view model and binding to a computed property instead.

    4. Reactivity

      • Accessing global objects doesn’t magically become reactive. If you want to update the UI when data changes, store and manipulate it in the view model, ensuring Aurelia’s change detection can pick it up.

    5. Clarity and Testing

      • Test heavy logic in a view model or service, not in templates. This approach keeps your code testable with unit tests and fosters a separation of concerns.

    By sticking to these guidelines, you can leverage Aurelia’s built-in global access without sacrificing maintainability or performance.

    <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 &lt;let&gt;) 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 aurelia
    npm 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>
    Use browser dev tools to confirm whether your event is fired and that the method in your view model is triggered.
    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
    -->
    attribute patterns
    type IAttrMapper
    documentation

    Structured route tree lets you describe nested layouts, auxiliary viewports, and fallback routes in one place while still co-locating child routes with their components via @route.

  • Viewport-first layouts allow flexible page composition: declare multiple <au-viewport> elements, name them, and target them from route definitions. This makes responsive shells and split views straightforward.

  • Lifecycle hooks and events mirror the intent of Angular guards (canLoad, canActivate, etc.) while using Aurelia conventions (canLoad, loading, canUnload, unloading). Hooks execute in well-defined order and support async work.

  • Navigation state management gives you centralized insight into route activation, title building, and analytics hooks, ideal for larger apps.

  • Progressive enhancement via the load and href attributes keeps markup readable and usable even before hydration.

  • Refer to the package README for release notes and API exports: packages/router/README.mdarrow-up-right.

    hashtag
    Choose the right guide

    Work through the topics in this order when you are new to the router:

    1. Fundamentals

      • Getting started

      • Router configuration

      • ·

    2. Navigation patterns

    3. Lifecycle, hooks, and events

    4. Advanced scenarios

    5. Support resources

    Keep the live StackBlitz examplesarrow-up-right handy while you read; most topics embed a runnable demo.

    hashtag
    Feature map

    Capability
    How to use it
    Related doc

    Configure base path, hash vs pushState, title building

    RouterConfiguration.customize and RouterOptions

    Map URLs to components with strong typing

    @route decorator inside your component

    hashtag
    Where to go next

    • Explore targeted recipes in the developer guidesarrow-up-right.

    • Pair routing with state management via the store plugin or your preferred data layer.

    • Review the router package CHANGELOGarrow-up-right when upgrading between versions.

    Choosing the right Aurelia routerarrow-up-right

    Conditional Rendering

    Learn about the various methods for conditionally rendering content in Aurelia 2, with detailed explanations and examples.

    Conditional rendering allows you to dynamically show or hide parts of your view based on your application's state. Aurelia 2 provides three primary directives for conditional rendering, each suited for different scenarios.

    hashtag
    Quick Reference

    Directive
    Use Case
    DOM Behavior
    Performance

    hashtag
    Using if.bind

    The if.bind directive conditionally adds or removes elements from the DOM based on a boolean expression. When the expression is false, Aurelia completely removes the element and its descendants, cleaning up resources, events, and custom elements.

    hashtag
    Basic Usage

    hashtag
    If/Else Structures

    Use else immediately after an if.bind element to create branching logic:

    hashtag
    Caching Behavior

    By default, if.bind caches views and view models for performance. Disable caching when you need fresh instances:

    circle-exclamation

    When to Use: Use if.bind when elements change infrequently and you want to completely remove them from the DOM to save memory and improve performance.

    hashtag
    Using show.bind

    The show.bind directive toggles element visibility without removing them from the DOM. This is equivalent to setting display: none in CSS.

    hashtag
    Basic Usage

    hashtag
    hide.bind (inverse of show.bind)

    hide is an alias of show with inverted logic:

    This is equivalent to:

    hashtag
    When to Use show.bind vs if.bind

    circle-info

    When to Use: Use show.bind when you need to frequently toggle visibility and want to preserve element state, bindings, and avoid re-initialization costs.

    hashtag
    Using switch.bind

    The switch.bind directive handles multiple conditions elegantly, similar to a JavaScript switch statement. It's ideal for enum values or when you have several mutually exclusive conditions.

    hashtag
    Basic Usage

    hashtag
    Grouping Cases

    Handle multiple values with a single case:

    hashtag
    Fall-Through Behavior

    Enable fall-through to show multiple cases:

    circle-info

    Fall-through is false by default. Set fall-through="true" or fall-through.bind="true" to enable it.

    hashtag
    Advanced Techniques

    hashtag
    Dynamic Switch Expressions

    Use computed expressions with switch.bind:

    hashtag
    Conditional Slot Projection

    Combine switch.bind with slots for dynamic content projection:

    hashtag
    Nested Switches

    Handle complex conditional logic with nested switches:

    hashtag
    Performance Guidelines

    hashtag
    Choosing the Right Directive

    • Frequent toggles: Use show.bind to avoid DOM manipulation overhead

    • Infrequent changes: Use if.bind to remove elements and save memory

    • Multiple conditions: Use switch.bind

    hashtag
    Optimization Tips

    hashtag
    Important Restrictions

    hashtag
    Case Usage Rules

    The case attribute must be a direct child of switch.bind:

    hashtag
    Default Case Placement

    Place default-case as the last option for best practices:

    Step 6: Route data + auth roles

    Use route data for roles and enforce access with a router hook.

    Optional step: This adds route data for role requirements and enforces it with a router hook. You will also add a small auth status widget to toggle the admin role in the UI. If you want to keep the tutorial beginner-friendly, you can stop after Step 5.

    hashtag
    1. Create a tiny auth service

    Create src/services/auth-service.ts:

    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] };
      }
    }

    hashtag
    2. Create a role-based router hook

    Create src/role-hook.ts:

    Register the hook in src/main.ts:

    hashtag
    3. Add protected routes with route data

    Create src/pages/admin-page.ts:

    Create src/pages/admin-page.html:

    Create src/pages/forbidden-page.ts:

    Create src/pages/forbidden-page.html:

    Update src/my-app.ts to add route data:

    hashtag
    4. Add an auth status widget

    Create src/components/auth-status.ts:

    Create src/components/auth-status.html:

    Update src/pages/dashboard-page.html to show it:

    Finally, add the Admin link in src/my-app.html:

    hashtag
    Recap

    • Route data carries the roles requirement.

    • The RoleHook reads next.data and redirects to forbidden if roles are missing.

    Back to:

    SVG

    A developer guide for enabling SVG binding in Aurelia 2.

    Learn about enabling SVG binding in Aurelia templates.

    hashtag
    Adding SVG Registration

    By default, Aurelia won't work with SVG elements since SVG elements and their attributes require different parsing rules (SVG uses XML namespaces and has different attribute handling than HTML). To teach Aurelia how to handle SVG element bindings, register the SVGAnalyzer:

    import { SVGAnalyzer } from '@aurelia/runtime-html';
    import { Aurelia } from 'aurelia';
    
    Aurelia
      .register(SVGAnalyzer) // <-- add this line
      .app(MyApp)
      .start();

    After adding this registration, bindings on SVG elements will work as expected.

    hashtag
    Basic SVG Bindings

    Once SVGAnalyzer is registered, you can bind to SVG attributes just like HTML attributes:

    hashtag
    Dynamic SVG Charts

    Create reactive charts with data binding:

    hashtag
    SVG Path Animations

    Bind to path data for dynamic shapes:

    hashtag
    Interactive SVG Elements

    Handle events on SVG elements:

    hashtag
    SVG with CSS Classes

    Apply dynamic classes to SVG elements:

    hashtag
    SVG Gradients with Bindings

    Create dynamic gradients:

    hashtag
    Supported SVG Elements

    The SVGAnalyzer supports all standard SVG elements including:

    • Basic shapes: circle, rect, ellipse, line, polyline, polygon, path

    • Text:

    hashtag
    Important Notes

    • Always register SVGAnalyzer before using SVG bindings

    • SVG attributes are case-sensitive (e.g., viewBox, not viewbox)

    • Use xlink:href

    hashtag
    Related Documentation

    • - Basic binding syntax

    • - Dynamic styling

    • - Iterating over data

    Scope and context

    Master the art of scope and binding context - the secret sauce behind Aurelia's powerful data binding magic.

    Ever wondered how Aurelia knows where to find your data when you write ${message} in a template? Or why $parent.something works like magic in nested components? Welcome to the world of scope and binding context – the invisible machinery that makes Aurelia's data binding so delightfully intuitive.

    Think of scope as Aurelia's GPS system for finding your data. Just like GPS needs to know your current location to give you directions, Aurelia's binding expressions need to know their current context to find the right data.

    circle-check

    Extending templating syntax

    The Aurelia template compiler is powerful and developer-friendly, allowing you extend its syntax with great ease.

    hashtag
    Context

    Sometimes you will see the following template in an Aurelia application:

    Aurelia understands that value.bind="message" means value.two-way="message", and later creates a two way binding between view model message

    App configuration and startup

    Configure Aurelia applications, register global resources, and choose the startup pattern that fits your project.

    hashtag
    Application Startup

    Aurelia provides two main approaches for application startup: a quick setup using static methods with sensible defaults, and a verbose setup that gives you complete control over configuration.

    Before you start: If you have not already chosen a project scaffold, walk through the for context on how this guide fits with enhancement, routing, and composition topics.

    https://codesandbox.io/p/devbox/bold-cache-cyn542?embed=1codesandbox.iochevron-right
  • Advanced API reference

  • Compose multiple viewports or named layouts

    <au-viewport> and named viewports

    Viewports

    Control navigation flow

    Lifecycle hooks (canLoad, loading, canUnload, unloading)

    Routing lifecycle

    Listen for navigation changes

    Router.addEventListener(...) or DI inject IRouterEvents

    Router events

    Persist and observe route state

    Inject ICurrentRoute / IRouter

    Router state management

    Customize transitions

    Provide a transitionPlan or set per-route strategies

    Transition plans

    Defining routes and viewports
    Viewports in depth
    Imperative navigation
    Build menus with the navigation model
    Accessing the active route
    Routing lifecycle
    Router hooks
    Router events
    Transition plans
    Router state management
    Error handling
    Testing guide
    Troubleshooting
    Router configuration
    Defining routes
    Logo
    for cleaner, more maintainable code

    if.bind

    Simple true/false conditions

    Adds/removes elements

    Best for infrequent changes

    show.bind

    Toggle visibility

    Hides/shows elements

    Best for frequent changes

    switch.bind

    Multiple conditions

    Adds/removes elements

    Best for enum-like values

    activeClass keeps the main nav styled for the active route.

    Extended Tutorial overview
    text
    ,
    tspan
    ,
    textPath
  • Containers: g, svg, defs, symbol, use

  • Gradients: linearGradient, radialGradient, stop

  • Filters: filter, feBlend, feColorMatrix, feGaussianBlur, and more

  • Animation: animate, animateTransform, animateMotion

  • for older SVG references, or just
    href
    for modern browsers
  • For better performance with many SVG elements, consider using one-time bindings for static values

  • Attribute Binding
    Class and Style Binding
    Repeat Rendering
    What you'll learn in this guide:
    • How scope and binding context work under the hood

    • The difference between binding context and override context (and why you should care)

    • How to navigate between parent and child scopes like a pro

    • When and why component boundaries matter

    • The magic behind $parent, $host, and other special keywords

    • How to debug those tricky scope-related issues

    hashtag
    The Big Picture: What is Scope?

    Before diving into the details, let's start with a simple analogy. Imagine you're at a family reunion, and someone shouts "Hey, John!" Three different people named John might turn around. To know which John they meant, you need context – are they talking to Uncle John, Cousin John, or Little Johnny?

    Aurelia faces the same challenge. When it sees ${name} in your template, it needs to know:

    • Which object contains the name property?

    • Should it look in the current component's data?

    • What about parent components?

    • Are there any special contextual values (like $index from a repeat.for)?

    This is where scope comes in. A scope is like a GPS coordinate that tells Aurelia exactly where to look for data.

    hashtag
    The JavaScript Analogy

    If you're familiar with JavaScript's function binding, you'll find this concept familiar:

    Just like JavaScript's this binding, Aurelia expressions need a context object to work with. The difference is that Aurelia's scope system is more sophisticated – it can look through multiple layers of context to find what it needs.

    hashtag
    Anatomy of a Scope

    Every scope in Aurelia has three main parts:

    hashtag
    1. Binding Context: Your Data Home

    The binding context is usually your component's instance – the object containing all your properties and methods:

    When Aurelia evaluates ${message} in the template, it looks at the binding context (your component instance) and finds the message property.

    hashtag
    2. Override Context: Special Contextual Values

    The override context holds special values that aren't part of your component but are available in templates. The most common example is loop variables:

    In this example:

    • item is added to a child scope's binding context

    • $index, $first, $last, etc. are added to the override context

    hashtag
    3. Scope Hierarchy and Parent Access

    Scopes form a hierarchy. Each component creates its own scope, and child components can access parent data using $parent:

    hashtag
    4. Component Boundaries

    Component boundaries are important to understand. When you create a custom element, Aurelia marks that scope as a boundary. This affects how property resolution works:

    • Within a component, Aurelia searches up the scope chain automatically

    • At component boundaries, you must use $parent explicitly to access parent component data

    hashtag
    5. The $host Keyword

    In slot projections, $host gives you access to the component that defines the slot:

    hashtag
    Debugging Scopes

    When you need to debug scope issues, you can access the scope in the browser console:

    Then in the browser console:

    hashtag
    Best Practices

    hashtag
    1. Keep Component Boundaries in Mind

    Always remember that component boundaries exist. If you need parent data, be explicit:

    hashtag
    2. Use Override Context Sparingly

    Override context is powerful but can be confusing. Use it for:

    • Template controller values ($index, $first, etc.)

    • Temporary view-only values (let bindings)

    • Slot projection context ($host)

    Avoid it for regular component data.

    hashtag
    3. Set Up Override Context Early

    If you need override context values, set them up during binding or earlier:

    hashtag
    4. Debug Scope Issues Systematically

    When debugging scope issues:

    1. Check the current scope structure

    2. Verify component boundaries

    3. Trace the property resolution path

    4. Test with explicit $parent usage

    hashtag
    Summary

    Scope and binding context are the foundation of Aurelia's data binding system. Understanding them helps you:

    • Write more predictable binding expressions

    • Debug data binding issues effectively

    • Use advanced features like slot projections

    • Create more maintainable component hierarchies

    Remember the key concepts:

    • Binding context = your component's data

    • Override context = special contextual values

    • Component boundaries = where automatic scope traversal stops

    • $parent = explicit parent access

    • $host = slot projection context

    With this knowledge, you're well-equipped to master Aurelia's data binding system and build sophisticated, data-driven applications!

    circle-info

    Want to learn more? Check out our guides on Custom Attributesarrow-up-right, Template Controllersarrow-up-right, and Shadow DOM and Slots to see scope and binding context in action.

    hashtag
    Quick startup

    The quick startup approach uses static methods on the Aurelia class and is the most common choice for new applications.

    hashtag
    Verbose Startup

    The verbose approach gives you complete control over the DI container and configuration. Use this when integrating Aurelia into existing applications or when you need fine-grained control.

    When to use verbose startup:

    • Integrating Aurelia into existing applications

    • Custom DI container configuration needed

    • Multiple Aurelia apps in one page

    • Advanced debugging or testing scenarios

    StandardConfiguration includes essential services like:

    • Template compiler and renderer

    • Binding engine and observers

    • Custom element/attribute support

    • Built-in value converters and binding behaviors

    • DOM event handling and delegation

    • Shadow DOM and CSS module support

    hashtag
    Registering Global Resources

    hashtag
    Registering a single custom element

    To make a custom element globally available throughout your application, register it before calling app().

    hashtag
    Registering multiple resources

    Group related components into resource modules for better organization.

    src/components/index.ts:

    src/main.ts:

    hashtag
    Registering other resource types

    hashtag
    Advanced Configuration

    hashtag
    Custom DI registrations

    hashtag
    Environment-specific configuration

    hashtag
    Enhancement Mode

    Sometimes you need Aurelia to light up markup that already exists in the DOM. Instead of calling app(), reach for Aurelia.enhance:

    Enhancement is ideal for progressive hydration, CMS integrations, or widgets embedded in non-Aurelia pages. You can register resources before enhancing, provide a custom DI container, and tear down the enhanced view by calling enhanceRoot.deactivate() when you’re done.

    For a full guide, including cleanup patterns, lifecycle hooks, and advanced recipes, see the dedicated Enhance article.

    hashtag
    Next steps

    • Continue with Enhance for progressive integration scenarios.

    • Wire services using dependency injection once your shell is running.

    • Explore choosing a routerarrow-up-right to add navigation after the app is bootstrapped.

    section overview
    <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!' }
    });
    property, and input
    value
    property. How does Aurelia know this?

    By default, Aurelia is taught how to interpret a bind binding command on a property of an element via a Attribute Syntax Mapper. Application can also tap into this class to teach Aurelia some extra knowledge so that it understands more than just value.bind on an <input/> element.

    hashtag
    Examples

    You may sometimes come across some custom input element in a component library, some examples are:

    • Microsoft FAST text-field element: https://explore.fast.design/components/fast-text-fieldarrow-up-right

    • Ionic ion-input element: https://ionicframework.com/docs/api/inputarrow-up-right

    • Polymer paper-input element:

    • and many more...

    Regardless of the lib choice an application takes, what is needed in common is the ability to have a concise syntax to describe the two way binding intention with those custom elements. Some examples for the above custom input elements:

    should be treated as:

    In the next section we will look into how to teach Aurelia such knowledge. Before diving in, keep the following mental model in mind:

    1. Attribute patterns (@attributePattern) split attribute names into target + command pairs. Use them when you want to teach the compiler new syntaxes such as [(value)]. See Attribute Patterns for a full walkthrough.

    2. Attribute syntax mapper (IAttrMapper) decides whether value.bind really means value.two-way, and how attribute names map onto DOM properties.

    3. Node observer locator (INodeObserverLocator) teaches the runtime how to observe those DOM properties (which events fire, whether values are readonly, etc.).

    All three steps are optional, but more advanced templating extensions usually need at least 2 and 3.

    hashtag
    Using the Attribute Syntax Mapper

    The Attribute Syntax Mapper decides which binding command Aurelia should use when you write .bind. Built-in rules already cover native elements (value.bind on <input> becomes .two-way, checked.bind on checkbox becomes .two-way, etc.). When you integrate with design systems or Web Components, you nearly always need to extend the mapper so that your terse syntax keeps working.

    Every Aurelia application uses a single mapper instance. Grab it with resolve(IAttrMapper) wherever you configure your app (typically via AppTask).

    IAttrMapper exposes:

    • useMapping(config) — map attributes (by tag name) to DOM properties.

    • useGlobalMapping(config) — same mapping, but applied to every tag.

    • useTwoWay(predicate) — force .bind to behave like .two-way for certain (element, attrName) pairs. attrName is the kebab-case attribute name; return true to enable two-way.

    Example: teach Aurelia that <fast-text-field value.bind="..."> should become value.two-way.

    hashtag
    Combining the attribute syntax mapper with the node observer locator

    Teaching Aurelia to map value.bind to value.two-way is the first half of the story. The second half ensures the runtime knows how to observe that DOM property. Do this via the Node Observer Locator. Retrieve it with resolve(INodeObserverLocator) from @aurelia/runtime:

    After grabbing the locator, call useConfig() (per-tag) or useConfigGlobal() (all tags). Each config object describes:

    • events: string[] — events to subscribe to.

    • readonly?: boolean — if true, Aurelia never writes to the property (useful for files).

    • default?: unknown — fallback value when a binding sets null/undefined.

    • type?: INodeObserverConstructor — provide a custom observer implementation.

    Example: watch <fast-text-field value> via the change event.

    Similarly, examples for <ion-input> and <paper-input>:

    circle-check

    If an object is passed to the .useConfig API of the Node Observer Locator, it will be used as a multi-registration call, as per following example, where we register <fast-text-field>, <ion-input>, <paper-input> all in a single call:

    nodeObserverLocator.useConfig({
      'FAST-TEXT-FIELD': {
        value: { events: ['change'] }
      },
      'ION-INPUT': {
        value: { events: ['change'] }
      },
      'PAPER-INPUT': {
        value: { events: ['change'] }
      }
    })

    hashtag
    Putting it together

    Combine both extensions inside AppTask.creating so they run before Aurelia instantiates your root component. The example below integrates a subset of Microsoft FASTarrow-up-right controls:

    With the above, your Aurelia application now understands the concise value.bind syntax and listens to the correct events:

    hashtag
    Troubleshooting and best practices

    • Duplicate mapping errors – IAttrMapper throws if you register the same tag/attribute twice. Remove or consolidate the previous registration before adding new rules.

    • Verify DOM property names – useMapping expects actual property names (valueAsNumber, formNoValidate, etc.). Typos silently fall back to camelCase conversion.

    • Mind attribute casing – The mapper receives attributes in kebab-case. If your component exposes camelCase properties (common for Web Components), map 'my-prop' → 'myProp'.

    • Use 'new' containers sparingly – When augmenting INodeObserverLocator, you rarely need custom observers. Prefer event-only configs before writing a new observer type.

    • Test with devtools – Toggle your custom elements in the browser and inspect element.value. If the value updates but Aurelia bindings do not, double-check the observer config. If bindings update but DOM does not, revisit useMapping.

    Once you understand the flow—pattern → mapper → observer—you can make nearly any third-party component feel native inside Aurelia templates.

    <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>

    Components

    Components are the fundamental building blocks of Aurelia applications. A component consists of a view-model (TypeScript class) and an optional view (HTML template) that work together to create reusable UI elements.

    hashtag
    Basic Component Structure

    Every Aurelia component starts with a simple class:

    And its corresponding HTML template (no <template> wrapper is needed in Aurelia 2):

    Template promises

    Aurelia 2 significantly simplifies the handling of Promises directly within your templates. Unlike previous versions where promise resolution typically occurred in the view model, Aurelia 2 empowers you to manage asynchronous operations directly in the view.

    This is accomplished through the promise.bind template controller. It intelligently manages the different states of a Promise: pending, resolved (then), and rejected (catch). This approach reduces boilerplate code and makes asynchronous data handling in templates more declarative and intuitive.

    Text interpolation

    Text interpolation allows you to display dynamic values in your views. By wrapping an expression with ${}, you can render variables, object properties, function results, and more within your HTML. This is conceptually similar to .

    hashtag
    Template expressions

    Expressions inside ${} can perform operations such as arithmetic, function calls, or ternaries:

    Step 3: Overview page + filters + events

    Add real data, reusable components, filters with query params, and event-driven updates.

    In this step you will add real data, reusable components, query param syncing, and deep child-to-parent communication with the Event Aggregator.

    hashtag
    1. Define shared models and data

    Create src/models.ts:

    Create src/project-data.ts

    https://www.webcomponents.org/element/@polymer/paper-inputarrow-up-right
    The ${message} syntax creates a binding between your view-model property and the template, automatically updating the UI when the property changes.

    hashtag
    When to Create a Component?

    Before creating a component, consider these guidelines:

    hashtag
    Create a component when:

    • ✅ You need reusable UI that appears in multiple places

    • ✅ The UI has its own behavior and state

    • ✅ You want to encapsulate complexity (a component should do one thing well)

    • ✅ The UI represents a meaningful concept in your domain (e.g., <user-card>, <product-list>)

    hashtag
    Use a custom attribute instead when:

    • ✅ You're adding behavior to existing elements without changing structure

    • ✅ You're creating a decorator or modifier (e.g., tooltip, draggable)

    • ✅ Multiple behaviors can be combined on the same element

    • ✅ Examples: <button tooltip="Save changes">, <div draggable sortable>

    hashtag
    Use a value converter when:

    • ✅ You're just formatting data for display

    • ✅ The transformation is pure (same input → same output)

    • ✅ Examples: ${date | dateFormat}, ${price | currency}

    hashtag
    Custom Elements

    To create reusable custom elements, use the @customElement decorator:

    hashtag
    Using Components

    After creating a component, you need to make it available for use. There are two ways to do this:

    hashtag
    Option 1: Import in Templates (Recommended for Most Cases)

    Import the component where you need it using the <import> element:

    This is the recommended approach because:

    • Components are only loaded where they're used

    • Better code organization and maintainability

    • Clear dependencies in each template

    hashtag
    Option 2: Global Registration

    Register components globally in your main.ts to use them anywhere without imports:

    Now <hello-world></hello-world> works in any template without <import>.

    When to use global registration:

    • Components used on almost every page (headers, footers, layout components)

    • Shared UI components used throughout the app

    • Components you want available in all templates by default

    When to use local imports:

    • Feature-specific components

    • Most custom components

    • Better tree-shaking and bundle optimization

    hashtag
    Bindable Properties

    Make component properties configurable from the outside using @bindable:

    Use the component by binding values to its properties:

    hashtag
    Component Lifecycle

    Components have lifecycle hooks for initialization and cleanup:

    hashtag
    Common Component Patterns

    hashtag
    Pattern: Container/Presenter (Smart/Dumb Components)

    Use case: Separate data management from presentation logic.

    Container (Smart) Component - Manages data and business logic:

    Presenter (Dumb) Component - Pure presentation, no data fetching:

    Why this works: Container components handle complexity (data, routing, state), while presenter components are simple, reusable, and easy to test. You can reuse <user-list> anywhere without worrying about data fetching.

    hashtag
    Pattern: Composition with Slots

    Use case: Create flexible container components that accept custom content.

    Usage:

    Why this works: Slots make components flexible without needing dozens of bindable properties. The component controls the structure while consumers control the content.

    hashtag
    Pattern: Form Components with Two-Way Binding

    Use case: Reusable form inputs that work seamlessly with parent form state.

    Usage:

    Why this works: Two-way binding with BindingMode.twoWay keeps the parent and child in sync automatically. Changes in either place propagate to both.

    hashtag
    Pattern: Stateful UI Components

    Use case: Components that manage their own internal state.

    Why this works: The component owns its UI state (which item is expanded) while accepting data as props. This keeps the parent simple - it just provides data, not UI state.

    hashtag
    Pattern: Event-Emitting Components

    Use case: Child components that notify parents of user actions.

    Usage:

    Why this works: Components can communicate via callbacks (tight coupling) or events (loose coupling) depending on your needs. Use callbacks for parent-child communication, events for unrelated components.

    hashtag
    Best Practices

    hashtag
    Keep Components Focused

    • ✅ Each component should have one clear responsibility

    • ✅ If a component is doing too much, split it into smaller components

    • ❌ Avoid "god components" that handle everything

    hashtag
    Favor Composition Over Inheritance

    • ✅ Use slots and component composition

    • ✅ Build complex UIs from simple, reusable pieces

    • ❌ Avoid deep inheritance hierarchies

    hashtag
    Make Components Predictable

    • ✅ Use bindable properties for inputs

    • ✅ Use callbacks or events for outputs

    • ✅ Document what bindables are required vs optional

    • ❌ Don't manipulate parent state directly

    hashtag
    Test-Friendly Components

    • ✅ Presenter components are easy to test (just props)

    • ✅ Keep business logic in services, not components

    • ✅ Use dependency injection for testability

    hashtag
    What's Next

    • Learn more about component lifecycles

    • Explore bindable properties in detail

    • Understand shadow DOM and slots for advanced composition

    hashtag
    Calling functions

    You can call functions defined on your view model. For example:

    hashtag
    Using ternaries

    You can also use ternary operations:

    This will display either "True" or "False" depending on the boolean value of isTrue.

    hashtag
    Complex expressions

    You can use more sophisticated expressions for dynamic content:

    hashtag
    Optional Syntax

    Aurelia supports the following optional chaining and nullish coalescing operators in templates:

    • ??

    • ?.

    • ?.()

    • ?.[]

    circle-exclamation

    Note that ??= is not supported.

    You can use these operators to safely handle null or undefined values:

    This helps avoid lengthy if-statements or ternary checks in your view model when dealing with potentially undefined data.

    hashtag
    HTMLElement Interpolation

    Aurelia supports passing HTMLElement objects directly to template interpolations. This allows you to dynamically create and insert DOM elements into your templates at runtime.

    hashtag
    Creating elements with document.createElement()

    You can create DOM elements in your view model and bind them directly:

    The button element will be directly inserted into the div, maintaining all its properties and event listeners.

    hashtag
    Parsing HTML strings

    You can also parse HTML strings and render the resulting elements:

    circle-exclamation

    Be cautious about the source of your HTML strings to avoid XSS vulnerabilities. Only do this with trusted content, or sanitize it first.

    hashtag
    Security Considerations

    When interpolating HTMLElements, be mindful of security implications:

    triangle-exclamation

    Never use innerHTML with user-provided content without proper sanitization. This can lead to XSS vulnerabilities.

    hashtag
    Dynamic element creation

    This feature is particularly useful for dynamic content scenarios:

    hashtag
    Notes on syntax

    While template interpolation is powerful, there are a few limitations to keep in mind:

    1. Aurelia parses expressions, not statements (so things like if, for, return, and function declarations are not supported inside ${...}).

    2. Some JavaScript tokens are repurposed: | is reserved for value converters and & is reserved for binding behaviors (so bitwise | / & are not available).

    3. The comma operator (,) is not supported.

    circle-info

    For complex transformations or formatting, consider using Aurelia's value converters instead of cramming too much logic into an interpolation.

    hashtag
    Performance Best Practices

    hashtag
    Avoid Complex Expressions

    Keep interpolation expressions simple for better performance. Complex computations should be moved to getters or methods:

    hashtag
    Array Observation Performance

    Aurelia automatically observes arrays used in interpolation. For large arrays that change frequently, consider using computed getters:

    hashtag
    Memory Considerations

    When using HTMLElement interpolation, ensure proper cleanup to avoid memory leaks:

    hashtag
    Error Handling and Edge Cases

    hashtag
    Handling Null and Undefined Values

    Interpolation gracefully handles null and undefined values by rendering empty strings:

    hashtag
    Error-Prone Expressions

    Some expressions can throw runtime errors. Use defensive patterns:

    hashtag
    Type Coercion Behavior

    Interpolation converts values to strings following JavaScript coercion rules:

    hashtag
    HTMLElement Edge Cases

    When interpolating HTMLElements, be aware of these behaviors:

    hashtag
    Advanced Example: Dynamic Content with Observer Updates

    Here's an example showing how interpolation works with Aurelia's observer system to automatically update the view when data changes:

    The interpolation is automatically updated by Aurelia's array observer whenever items are added to the collection.

    JavaScript template literalsarrow-up-right
    :

    hashtag
    2. Build reusable components

    Create src/components/project-card.ts:

    Create src/components/project-card.html:

    The route: instruction uses a route id. Because this link lives inside the Overview child route, we prefix with ../ so the router resolves the id from the parent (Projects) route context.

    Create src/components/task-list.ts:

    Create src/components/task-list.html:

    Create src/components/task-item.ts:

    Create src/components/task-item.html:

    hashtag
    3. Replace the Overview page with real behavior

    Update src/pages/projects-overview-page.ts:

    Update src/pages/projects-overview-page.html:

    The loading hook reads the query params from next.queryParams. The Share Filter button uses IRouter.load() to update the URL.

    hashtag
    4. Replace the Activity page with real data

    Update src/pages/projects-activity-page.ts:

    Update src/pages/projects-activity-page.html:

    Next step: Step 4: Detail route + guards

    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);
        });
      }
    }
    Addition example
    <p>Quick maths: ${2 + 2}</p>
    <!-- Outputs "Quick maths: 4" -->
    my-app.ts
    export class MyApp {
      adder(val1: number, val2: number): number {
        return parseInt(val1) + parseInt(val2);
      }
    }
    my-app.html
    <p>Behold mathematics, 6 + 1 = ${adder(6, 1)}</p>
    <!-- Outputs "Behold mathematics, 6 + 1 = 7" -->
    my-app.html
    <p>${isTrue ? 'True' : 'False'}</p>
    Array operations
    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' }
      ];
    }
    Object property access
    export class MyApp {
      user = {
        profile: {
          personal: { firstName: 'John', lastName: 'Doe' },
          settings: { theme: 'dark', notifications: true }
        }
      };
    }
    Conditional and logical operations
    <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>
    Optional chaining and nullish coalescing
    <p>User Name: ${user?.name ?? 'Anonymous'}</p>
    my-app.ts
    export class MyApp {
      content = document.createElement('button');
    
      constructor() {
        this.content.textContent = 'Click me!';
        this.content.addEventListener('click', () => {
          alert('Button clicked!');
        });
      }
    }
    my-app.html
    <div>${content}</div>
    my-app.ts
    export class MyApp {
      content = (() => {
        const tpl = document.createElement('template');
        tpl.innerHTML = '<button>Parsed Button</button>';
        return tpl.content.firstElementChild as HTMLElement;
      })();
    }
    my-app.html
    <div>${content}</div>
    Safe practices
    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;
      }
    }
    my-app.ts
    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);
      }
    }
    my-app.html
    <button click.trigger="addElement()">Add Element</button>
    <div repeat.for="element of elements">${element}</div>
    ❌ Not recommended
    <p>${items.filter(i => i.active).map(i => i.name.toUpperCase()).join(', ')}</p>
    ✅ Better approach
    export class MyApp {
      get activeItemNames() {
        return this.items
          .filter(i => i.active)
          .map(i => i.name.toUpperCase())
          .join(', ');
      }
    }
    Large array optimization
    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;
      }
    }
    Proper cleanup example
    export class MyApp {
      elements: HTMLElement[] = [];
    
      detaching() {
        // Clean up event listeners and references
        this.elements.forEach(el => {
          el.removeEventListener('click', this.handleClick);
        });
        this.elements = [];
      }
    }
    Null/undefined handling
    export class MyApp {
      name: string | null = null;
      data: any = undefined;
    }
    ❌ Can throw errors
    <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 -->
    ✅ Defensive patterns
    <p>${user?.profile?.name ?? 'Anonymous'}</p>
    <p>${items[selectedIndex]?.title ?? 'No item selected'}</p>
    <p>${safeCalculateTotal()}</p>
    Type conversion examples
    export class MyApp {
      number = 42;
      boolean = true;
      array = [1, 2, 3];
      object = { name: 'test' };
    }
    HTMLElement considerations
    export class MyApp {
      nullElement: HTMLElement | null = null;
      detachedElement = document.createElement('div');
    
      constructor() {
        this.detachedElement.textContent = 'Detached';
        // Element not in DOM yet
      }
    }
    my-app.ts
    export class MyApp {
      items = [];
    
      constructor() {
        this.items.push({ name: 'Item 1' }, { name: 'Item 2' });
      }
    
      addItem() {
        this.items.push({ name: `Item ${this.items.length + 1}` });
      }
    }
    my-app.html
    <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>
    circle-info

    You may also see promise.resolve="..." in older examples. It’s an alias for promise.bind="...".

    hashtag
    Basic Usage

    The promise.bind attribute allows you to bind a Promise to a template, rendering different content based on the Promise's current state.

    In this example:

    • promise.bind="myPromise": Binds the div to the Promise named myPromise in your view model.

    • <template pending>: Content rendered while myPromise is in the pending state (still resolving).

    • <template then="data">: Content rendered when myPromise resolves successfully. The resolved value is available as data within this template.

    • <template catch="error">: Content rendered if myPromise rejects. The rejection reason (typically an Error object) is available as error.

    hashtag
    Simple Example with Different Promise States

    Let's illustrate with a view model that manages different promise scenarios:

    <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);
        });
      }
    }

    In this example, promise1 is set to resolve after 2 seconds, and promise2 is set to reject after 3 seconds. The template dynamically updates to reflect each promise's state. Notice in promise2's then template, we don't specify a variable, indicating we only care about the resolved state, not the resolved value itself.

    hashtag
    Promise Binding with Functions and Parameters

    You can directly bind a function call to promise.bind. Aurelia is smart enough to re-invoke the function only when its parameters change, treating function calls in templates as pure operations.

    The following example fetches a random advice slip from an API each time a button is clicked:

    <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
        });
      }
    }

    Key Points:

    • adviceIndex: This variable, initialized with let adviceIndex.bind="0", acts as a parameter to fetchAdvice. Incrementing adviceIndex via the button click triggers Aurelia to re-evaluate fetchAdvice(adviceIndex).

    • Function Re-execution: Aurelia re-executes fetchAdvice only when adviceIndex changes, ensuring efficient handling of function-based promises.

    • Error Handling: The .catch template gracefully handles fetch errors, providing user-friendly feedback and a "Try Again" button.

    hashtag
    Isolated Promise Binding Scope

    The promise.bind template controller creates its own isolated scope. This is crucial to prevent naming conflicts and unintended modification of the parent view model or scope.

    In this example:

    • userData and userError: These variables are scoped only within the promise.bind context. They do not pollute the parent view model scope.

    • Component Communication: To pass data to child components (like <user-profile>), use property binding (e.g., user-data.bind="userData").

    • Parent Scope Access (Discouraged): While you can access the parent scope using $parent, it's generally better to manage data flow through explicit bindings and avoid relying on parent scope access for maintainability.

    hashtag
    Nested Promise Bindings

    Aurelia 2 supports nesting promise.bind controllers to handle scenarios where one asynchronous operation depends on the result of another.

    Flow of Execution:

    1. initialFetchPromise: The outer promise.bind starts with initialFetchPromise.

    2. Pending State: While initialFetchPromise is pending, "Fetching initial data..." is displayed.

    3. First then (Response): When initialFetchPromise resolves, the resolved value (initialResponse) becomes available in the then template.

    4. Nested promise.bind (JSON Deserialization): Inside the first then template, a nested promise.bind is used: promise.bind="initialResponse.json()". This starts a new promise based on deserializing the initialResponse.

    5. Nested then (JSON Data): When initialResponse.json() resolves, the parsed JSON data (jsonData) is available in this then template. "Data received and deserialized: ${jsonData.name}" is displayed.

    6. Nested catch (JSON Error): If initialResponse.json() fails (e.g., invalid JSON), the nested catch template handles the error.

    7. Outer catch (Fetch Error): If initialFetchPromise initially rejects, the outer catch template handles the initial fetch error.

    hashtag
    Promise Bindings in repeat.for Loops

    When using promise.bind within a repeat.for loop, it's crucial to manage scope correctly, especially if you need to access data from each promise iteration. Using let bindings within the <template promise.bind="..."> is highly recommended to create proper scoping for each iteration.

    Importance of <let> Bindings:

    • Scoped Context: The lines <let itemData.bind="null"></let> and <let itemError.bind="null"></let> inside the promise.bind template are essential. They create itemData and itemError properties in the overriding context of each promise.bind iteration.

    • Preventing Overwriting: Without these let bindings, itemData and itemError would be created in the binding context, which is shared across all iterations of the repeat.for loop. This would lead to data from later iterations overwriting data from earlier ones, resulting in incorrect or unpredictable behavior.

    • Correct Output: With let bindings, each iteration of the repeat.for loop gets its own isolated scope for itemData and itemError, ensuring correct rendering for each promise in the list.

    <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
        });
      }
    }

    Form Submission

    Learn how to handle form submissions with proper state management, error handling, and user feedback.

    hashtag
    Basic Form Submission

    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>

    hashtag
    Preventing Default Submission

    <!-- 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
    }

    hashtag
    Validation Before Submission

    hashtag
    Submission State Management

    hashtag
    Optimistic UI Updates

    hashtag
    Debounced Auto-Save

    hashtag
    Rate Limiting

    hashtag
    Multi-Step Forms

    See the for complete examples, including community-contributed multi-step flows.

    hashtag
    Best Practices

    1. Always provide feedback - Show loading, success, and error states

    2. Disable submit button - Prevent multiple submissions

    3. Handle errors gracefully - Show user-friendly error messages

    hashtag
    Common Patterns

    hashtag
    Submit on Enter Key

    hashtag
    Confirm Before Submit

    hashtag
    Redirect After Success

    hashtag
    Related

    Visual Diagrams

    Visual diagrams to help understand Aurelia's templating system. Every diagram below is rendered with GitBook-friendly Mermaid so it stays legible in dark and light modes.

    hashtag
    Data Binding Flow

    hashtag
    One-Way Binding (View Model → View)

    Use one-way bindings for read-only flows or whenever the DOM only needs to reflect state.

    hashtag
    Two-Way Binding (View Model ↔ View)

    Two-way bindings keep inputs and view-model properties in sync. Typing "Alice" updates name, which in turn refreshes every binding that depends on it.

    hashtag
    From-View Binding (View → View Model)

    .from-view captures user input without pushing view-model changes back into the DOM—handy for debounced searches or analytics where the DOM already mirrors the value elsewhere.

    hashtag
    Binding Mode Decision Tree

    hashtag
    Conditional Rendering: if vs show

    hashtag
    if.bind – Adds/Removes from the DOM

    if.bind creates and disposes the DOM subtree. It frees memory and automatically detaches listeners any time the condition flips back to false.

    hashtag
    show.bind – CSS Display Toggle

    show.bind toggles display: none without touching the DOM tree. It is ideal for frequently toggled sections that should keep their internal state alive.

    hashtag
    Decision Matrix

    hashtag
    List Rendering with repeat.for

    hashtag
    Basic Flow

    hashtag
    With Keys for Efficient Updates

    By default, the repeat controller tracks scopes by the actual item reference. When you insert X in between existing objects ([A, B, C] → [A, X, B, C]), Aurelia reuses the same scopes for A, B, and C because their references are unchanged; only X produces a new view. The _scopeMap maintained inside packages/runtime-html/src/resources/template-controllers/repeat.ts (see _createScopes and _applyIndexMap) stores either the raw item reference or your explicit key, which is why Aurelia can diff without re-rendering.

    Provide a key only when you recreate objects between refreshes (for example, mapping API data into new literals) or when the list contains primitives. In those cases a property such as id gives Aurelia a stable identity to match.

    hashtag
    Contextual Properties

    Property
    Description
    Example Values (list of 3)

    hashtag
    Event Binding: Trigger vs Capture

    hashtag
    Bubbling Phase (.trigger)

    .trigger listens during the bubble phase as the event travels from the target back toward the window.

    hashtag
    Capturing Phase (.capture)

    .capture intercepts the event on its way down the DOM tree before child handlers run.

    hashtag
    Event Flow Complete Picture

    hashtag
    Value Converters Pipeline

    hashtag
    Converter Flow Detail

    hashtag
    Component Communication

    hashtag
    Parent → Child (Bindable Properties)

    hashtag
    Child → Parent (Callback Binding)

    Use .bind to pass a callback reference to the child now that the deprecated .call binding command is gone.

    hashtag
    Form Checkbox Collections

    When the user checks "Keyboard", Aurelia pushes 2 into selectedIds. Unchecking "Mouse" removes 1, keeping the array aligned with the checked boxes.

    hashtag
    Template Lifecycle

    hashtag
    Performance: Binding Modes Comparison

    Binding Mode
    Setup Cost
    Updates
    Memory Footprint
    Typical Use

    Internals note: the PropertyBinding.bind implementation wires observers based on the binding mode flags. .one-time evaluates the expression once without connecting, .to-view connects the source side so it can re-run when dependencies change, and .bind/.two-way also subscribes to the target observer (for example, an input element) so user input flows back to the view model. This mirrors the logic in packages/runtime-html/src/binding/property-binding.ts where toView, fromView, and oneTime determine which observers are created.

    hashtag
    Computed Properties Reactivity

    Aurelia re-runs getters whenever any accessed dependency (the array itself or a member property) mutates, then propagates the new value into the DOM.

    hashtag
    Related Documentation

    Dependency Injection

    Aurelia's dependency injection (DI) system manages your application's services and their dependencies automatically, promoting clean architecture and testable code.

    hashtag
    Creating Services

    Services are regular classes that encapsulate state and call out to other dependencies. Rather than assigning collaborators manually, grab them from the container via resolve():

    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;
      }
    }

    hashtag
    Service Registration

    Register services using the interface pattern so components depend on tokens, not classes:

    When this module loads the container wires IUserService to a singleton UserService instance. Swapping implementations (e.g., a mock service in tests) only requires a different registration.

    hashtag
    Using Services in Components

    Use the resolve() function to inject services into components:

    Why resolve()?

    • Clean, modern syntax

    • No decorators needed

    • Better TypeScript inference

    • Easier to test

    hashtag
    Service Dependencies

    Services can depend on other services using resolve():

    You can resolve multiple dependencies - Aurelia handles the wiring automatically.

    hashtag
    Service Lifetimes

    Control how services are instantiated:

    hashtag
    Configuration Services

    Create configuration objects for your services:

    hashtag
    Resolver toolbox

    The DI container ships a set of resolver helpers in @aurelia/kernel. Resolvers change how a dependency is located at runtime—perfect for optional services, per-scope instances, or discovering every implementation of an interface. Every resolver works both as a decorator (@all(IMetricSink)) and inside resolve(...).

    Resolver
    Example
    What it does

    hashtag
    Creating custom resolvers

    If none of the built-ins fit, use createResolver to craft your own semantics. The helper wires up decorator + runtime support automatically:

    Because resolvers are plain DI registrations, you can package them inside libraries or register them globally via Aurelia.register(...), keeping consumption ergonomic in templates and services alike.

    hashtag
    Testing with DI

    DI makes testing straightforward by allowing easy mocking:

    hashtag
    What's Next

    • Learn more about

    • Explore

    • Understand for advanced scenarios

    Hello World Tutorial

    Learn the basics of Aurelia by building an interactive Hello, World! application from scratch

    Build your first Aurelia app in 10 minutes! This complete guide takes you from zero to a working interactive application with live data binding.

    hashtag
    What You'll Build

    An interactive hello world app where typing in a text field instantly updates the greeting. No page refreshes, no complex setup - just pure Aurelia magic.

    hashtag
    Prerequisites

    • Recent version of installed

    • Basic knowledge of JavaScript, HTML, and CSS

    hashtag
    Step 1: Create Your Project

    Open your terminal and create a new Aurelia project:

    When prompted:

    • Project name: hello-world

    • Setup: Choose TypeScript or ESNext (JavaScript)

    • Install dependencies: yes

    Navigate to your project and start the development server:

    A browser window opens showing "Hello World". Congratulations! You just ran your first Aurelia app.

    hashtag
    Step 2: Understand the Basics

    Aurelia apps are built with components that have two parts:

    • View-model (.ts/.js): Your logic and data

    • View (.html): Your template

    Open src/my-app.ts to see your first view-model:

    And src/my-app.html for the view:

    The ${message} syntax is string interpolation - it displays the value from your view-model.

    hashtag
    Step 3: Create an Interactive Component

    Let's build something more interesting. Create a new component for personalized greetings.

    Create src/hello-name.ts:

    Create src/hello-name.html:

    The magic is in value.bind="name" - this creates two-way binding between the input and your view-model property. Change one, and the other updates automatically.

    hashtag
    Step 4: Use Your Component

    Update src/my-app.html to use your new component:

    Important: The <import> element is required to use your component. It tells Aurelia to load the hello-name component from the specified path. Without it, the <hello-name> tag won't work and nothing will render (with no error message).

    Alternative: You can also in main.ts if you want to use them everywhere without imports.

    hashtag
    Step 5: Test Your App

    Save your files and watch the browser automatically refresh. You'll see:

    • A heading that says "Hello, World!"

    • A text input with "World" pre-filled

    • As you type in the input, the heading updates instantly

    That's it! You've built a reactive Aurelia application with:

    • Custom components

    • Data binding

    • Real-time updates

    hashtag
    Key Concepts You Learned

    1. Components: Building blocks made of view-models and views

    2. String Interpolation: ${property} displays data in templates

    3. Two-way Binding: value.bind keeps input and data synchronized

    hashtag
    Next Steps

    Ready to dive deeper? Explore:

    • - loops, conditionals, and more bindings

    • - hooks for advanced behavior

    • - sharing services between components

    The fundamentals you learned here apply to every Aurelia app you'll build. Start experimenting and see what you can create!

    From React to Aurelia

    React developers: Discover why Aurelia's standards-based approach delivers better performance and cleaner code without the complexity.

    Coming from React? You'll love Aurelia's approach to component development. Get the productivity of React with better performance, cleaner templates, and no virtual DOM overhead.

    hashtag
    Why React Developers Choose Aurelia

    hashtag

    Using the template compiler

    The template compiler is used by Aurelia under the hood to process templates and provides hooks and APIs allowing you intercept and modify how this behavior works in your applications.

    hashtag
    Hooks

    There are scenarios where an application wants to control how to preprocess a template before it is compiled. There could be various reasons, such as accessibility validation, adding debugging attributes etc...

    Aurelia supports this via template compiler hooks, enabled with the default template compiler. To use these features, declare and then register the desired hooks with either global (at startup) or local container (at dependencies (runtime) or <import> with convention).

    Validate before submitting - Client-side validation for UX
  • Reset form after success - Clear form or redirect user

  • Implement rate limiting - Prevent spam submissions

  • Use proper HTTP methods - POST for creation, PUT/PATCH for updates

  • Template Recipes collection
    Form Basics
    Validationarrow-up-right
    File Uploads
    Form Examplesarrow-up-right

    resolve(lazy(IHttpClient))

    Injects a function that resolves the dependency on demand.

    optional(key) / own(key)

    resolve(optional(IMaybeService))

    Returns undefined (or the child container value) when nothing is registered.

    factory(key)

    resolve(factory(MyModelClass))

    Gives you a function that constructs the service manually (passing constructor args if needed).

    newInstanceForScope(key)

    resolve(newInstanceForScope(IValidationController))

    Creates and registers a brand-new instance in the current component scope, making it available to descendants via resolve(IValidationController).

    newInstanceOf(Type)

    resolve(newInstanceOf(Logger))

    Constructs a fresh instance of a concrete class or interface implementation without polluting the container.

    resource(key) / optionalResource(key) / allResources(key)

    resolve(optionalResource(MyElement))

    Resolves using resource semantics (look in the current component first, then root) which is handy for templating resources.

    ignore

    @ignore private unused?: Foo

    Tells the container to skip a constructor parameter completely.

    all(key)

    resolve(all(IMetricSink))

    Returns all registrations for a key (useful for plugin pipelines).

    last(key)

    resolve(last(ISink))

    Grabs the most recently registered instance.

    dependency injection concepts
    service creation patterns
    DI resolvers

    lazy(key)

    An example of declaring global hooks that will be called for every template:

    hashtag
    With VanillaJS

    hashtag
    With decorator

    hashtag
    Supported hooks

    • compiling: this hook will be invoked before the template compiler starts compiling a template. Use this hook if there need to be any changes to a template before any compilation.

    hashtag
    Hooks invocation order

    All hooks from local and global registrations will be invoked: local first, then global.

    hashtag
    Compilation Behavior

    The default compiler will remove all binding expressions while compiling a template. This is to clean the rendered HTML and increase the performance of cloning compiled fragments.

    Though this is not always desirable for debugging, it could be hard to figure out what element mapped to the original part of the code. To enable an easier debugging experience, the default compiler has a property debug that when set to true will keep all expressions intact during the compilation.

    This property can be set early in an application lifecycle via AppTask, so that all the rendered HTML will keep their original form. An example of doing this is:

    List of attributes that are considered expressions:

    • containerless

    • as-element

    • ref

    • attr with binding expression (attr.command="...")

    • attr with interpolation (attr="${someExpression}")

    • custom attribute

    • custom element bindables

    hashtag
    Scenarios

    Now that we understand how the template compiler works let's create fun scenarios showcasing how you might use it in your Aurelia applications.

    hashtag
    Feature Flagging in Templates

    If your application uses feature flags to toggle features on and off, you may want to modify templates based on these flags conditionally.

    Here, elements with a data-feature attribute will be removed from the template if the corresponding feature flag is set to false, allowing for easy management of feature rollouts.

    hashtag
    Auto-Generating Form Field IDs for Label Association

    For accessibility purposes, form fields must associate label elements with matching for and id attributes. We can automate this process during template compilation.

    In this use case, the hook generates a unique id for each form field that doesn't already have one and updates the corresponding label's for attribute to match. This ensures that form fields are properly labelled for screen readers and other assistive technologies.

    hashtag
    Automatic ARIA Role Assignment

    To enhance accessibility, you might want to automatically assign ARIA roles to certain elements based on their class or other attributes to make your application more accessible without manually annotating each element.

    This hook assigns the role="button" to all elements that have the .btn class and do not already have a role defined. This helps ensure that custom-styled buttons are accessible.

    hashtag
    Content Security Policy (CSP) Compliance

    If your application needs to comply with strict Content Security Policies, you should ensure that inline styles are not used within your templates. A template compiler hook can help you enforce this policy.

    This hook scans for any elements with inline style attributes and removes them, logging a warning for developers to take notice and refactor the styles into external stylesheets.

    hashtag
    Lazy Loading Image Optimization

    For performance optimization, you should implement lazy loading for images. The template compiler can automatically add lazy loading attributes to your image tags.

    This hook finds all img elements without a loading attribute and sets it to lazy, instructing the browser to defer loading the image until it is near the viewport.

    hashtag
    Dynamic Theme Class Injection

    If your application supports multiple themes, you can use a template compiler hook to inject the relevant theme class into the root of your templates based on user preferences.

    This hook adds a theme-specific class to the root element of every template, allowing for theme-specific styles to be applied consistently across the application.

    hashtag
    Node APIs used

    The default template compiler will turn a template, either in string or already an element, into an element before the compilation. During the compilation, these APIs on the Node & Element classes are accessed and invoked:

    • Node.prototype.nodeType

    • Node.prototype.nodeName

    • Node.prototype.childNodes

    • Node.prototype.childNode

    • Node.prototype.firstChild

    • Node.prototype.textContent

    • Node.prototype.parentNode

    • Node.prototype.appendChild

    • Node.prototype.insertBefore

    • Element.prototype.attributes

    • Element.prototype.hasAttribute

    • Element.prototype.getAttribute

    • Element.prototype.setAttribute

    • Element.prototype.classList.add

    If it is desirable to use the default template compiler in any environment other than HTML, ensure the template compiler can hydrate the input string or object into some object with the above APIs.

    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();

    Slightly slower

    Instant

    Event cleanup

    Automatic

    Handled manually if needed

    Component init

    Runs every attach

    Runs once

    Best for

    Rare toggles

    Frequent toggles

    True only for the last item

    false, false, true

    $even

    True when $index % 2 === 0

    true, false, true

    $odd

    True when $index % 2 === 1

    false, true, false

    $length

    Total length of the iterable

    3

    item

    Current iteration value

    'Apple', 'Banana', 'Cherry'

    Set value + observer

    Whenever property changes

    One source observer

    Displaying reactive state

    .bind (.two-way)

    Bidirectional observers

    View ↔ ViewModel

    Source observer + DOM listener

    Form controls that read/write

    Event Binding

  • Value Converters

  • Capability

    if.bind

    show.bind

    DOM manipulation

    Create/destroy nodes

    Toggle CSS display

    Memory

    Released when hidden

    Always allocated

    $index

    Zero-based index

    0, 1, 2

    $first

    True only for the first item

    true, false, false

    <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);
      }
    }

    .one-time

    Set value once

    Never updates

    No observers hooked up

    Static text that never changes

    items = [
      { price: 10, qty: 2 },
      { price: 20, qty: 1 }
    ];
    
    get total() {
      return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
    }
    Template Syntax Overview
    Cheat Sheet
    Conditional Rendering
    List Rendering

    Toggle speed

    $last

    .to-view

    Component Registration: <import> and custom element tags

  • Conventions: File names become component names

  • - multi-page applications
    npx makes aurelia
    cd hello-world
    npm start
    export 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>
    Node.jsarrow-up-right
    register components globally
    Template Syntax
    Component Lifecycle
    Dependency Injection
    Router
    🚀 Performance That Actually Matters

    Result: 30-50% faster rendering with smaller bundle sizes.

    hashtag
    ✨ Cleaner Component Code

    No hooks complexity. No re-render cycles. Just clean, maintainable code.

    hashtag
    🎯 TypeScript-First Development

    hashtag
    🔥 Better Developer Experience

    Feature
    React
    Aurelia

    Component State

    useState, useReducer

    Simple class properties

    Side Effects

    useEffect with dependencies

    @watch decorator or lifecycle hooks

    hashtag
    Your React Knowledge Transfers

    hashtag
    JSX → Aurelia Templates

    hashtag
    Props → Bindable Properties

    hashtag
    State Management

    hashtag
    Quick Migration Path

    hashtag
    1. Start with Familiar Concepts (5 minutes)

    hashtag
    2. Convert a React Component (10 minutes)

    Create your first Aurelia component using familiar React patterns:

    hashtag
    3. Experience the Differences (5 minutes)

    • No build step complexity - just works with any bundler

    • No prop drilling - dependency injection handles state

    • No re-render debugging - direct DOM updates

    • No hooks confusion - simple class properties and methods

    hashtag
    What You'll Gain

    hashtag
    📈 Performance Benefits

    • Faster initial load - no virtual DOM library overhead

    • Faster updates - direct DOM manipulation

    • Smaller bundles - efficient code splitting

    • Better mobile performance - less JavaScript execution

    hashtag
    🧹 Cleaner Codebase

    • Less boilerplate - no prop interfaces, no memo wrapping

    • Intuitive templates - HTML that looks like HTML

    • Simpler state management - class properties instead of hooks

    • Better separation of concerns - HTML, CSS, and TypeScript in separate files

    hashtag
    🚀 Better Developer Experience

    • Stronger TypeScript integration - built from the ground up for TypeScript

    • No re-render optimization needed - automatically efficient

    • Powerful CLI tools - scaffolding and build tools that just work

    • Excellent debugging - inspect actual DOM, not virtual representations

    hashtag
    Ready to Make the Switch?

    Next Steps:

    1. Complete Getting Started Guide - Build your first app in 15 minutes

    2. Component Guide - Master Aurelia's component model

    3. Templates Deep Dive - Learn the templating system

    4. Migration Examples - See more migration patterns

    Questions? Join our Discord communityarrow-up-right where developers discuss their experiences with different frameworks.

    Ready to experience the difference? Start building with Aurelia now.

    // 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

    The Aurelia Philosophy

    These are our fighting words

    The web development industry has lost its mind. Frameworks reinvent perfectly good web standards. Developers rewrite applications every eighteen months to chase the new hotness. Testing requires PhD-level framework knowledge just to mock a simple service.

    We refuse to participate in this insanity.

    We believe frameworks should enhance the web, not replace it. We believe your knowledge should compound, not expire. We believe testing should be trivial, not heroic.

    Most importantly, we believe you shouldn't have to forget everything you know about web development just to use our framework.

    These beliefs have made us unfashionable. Good. We'd rather be right than trendy.

    hashtag
    Why Not Just Use React?

    Because the biggest crowd is not the only path to victory.

    React is the industry default. It commands stadium keynotes, spawns endless packages, and fills job boards with requirements. We salute the React team for making component thinking mainstream and showing the browser can power ambitious interfaces. That legacy is undeniable.

    But scale changes the game. React's strength is modular freedom. Every decision, from routing to state management to forms to dependency injection to testing, invites you to draft your own lineup of supporting libraries. That freedom can be exhilarating. It can also demand constant attention as packages shift, maintainers rotate out, and best practices rewrite themselves overnight. Teams do not just learn React; they learn React plus the custom stack they assembled on day one and have to re-evaluate each quarter.

    Aurelia stays intentionally smaller so we can ship the essentials as one cohesive strike force. Our router, dependency injection, binding engine, and testing story are forged together by the same team with the same philosophy. You spend your energy shipping features, not benchmarking which combination of libraries will still be supported next quarter. When we add capability, we do it without erasing the knowledge you already earned.

    Choosing Aurelia means joining a community that prizes stability over hype, standards over novelty, and craftsmanship over churn. You gain direct access to maintainers who care about your use case, not just the next keynote demo. Our ecosystem is tighter, but it is aligned, predictable, and built to compound value release after release.

    Use React if you want the world of mix-and-match options. Choose Aurelia if you want a unified framework that already lives the principles this manifesto defends.

    hashtag
    Web Standards, Enhanced

    We build on the web, not around it.

    The web platform is brilliant. HTML gives us declarative markup that's intuitive to read and write. CSS provides powerful styling capabilities that scale from simple pages to complex applications. JavaScript offers a flexible, evolving language that runs everywhere. These aren't bugs to be fixed. They're features to be leveraged.

    Yet somewhere along the way, the industry decided the web platform wasn't sophisticated enough for "real" applications. Everyone needed their own templating language that looked nothing like HTML. Their own styling solution that avoided CSS. Their own module system that ignored JavaScript's evolution. Learn our special syntax. Forget everything you know about web development. Trust us, we know better than the web standards committees and the collective wisdom of millions of developers.

    This is breathtaking arrogance disguised as innovation.

    Consider what this means in practice: A developer who's spent years mastering HTML, CSS, and JavaScript sits down with a modern framework and discovers that none of that knowledge applies. The templating syntax is completely foreign. The styling approach bypasses CSS entirely. The component model bears no resemblance to anything they've learned about web development.

    We're essentially telling experienced web developers that their expertise is worthless. That the web platform they've mastered is inadequate. That they need to start over with our special way of doing things.

    Aurelia takes the opposite approach. We enhance web standards instead of replacing them. Your templates are HTML with binding attributes that read like natural language: value.bind, click.trigger, repeat.for. A web developer can look at Aurelia templates and immediately understand what's happening, even if they've never seen the framework before.

    Your components are JavaScript classes with predictable lifecycle methods. No magical decorators that fundamentally change how JavaScript works. No special compilation steps that transform your code into something unrecognizable. Just classes with methods that get called at logical points in the component's life.

    Your styles are CSS. Not CSS-in-JS. Not a special styling language. Not a build-time transformation that generates CSS for you. Just CSS, working exactly like CSS should work, with all the power and flexibility you expect.

    This philosophy extends to how we handle emerging standards. When Web Components were still experimental, we didn't bet the entire framework on them. When they became stable, we didn't ignore them. We built compatibility layers that let you use Aurelia components as Web Components when that makes sense, while keeping our core architecture independent of any single standard's success or failure.

    We've watched frameworks rise and fall based on their alignment with web standards. The ones that fought against the platform eventually lost. The ones that embraced it survived and thrived. We're not just building for today's web. We're building for the web's long-term evolution.

    When you learn Aurelia, you're not just learning a framework. You're deepening your understanding of the web platform itself. The binding concepts translate to vanilla JavaScript. The component patterns work with or without the framework. The architectural principles apply to any web application.

    Your knowledge doesn't expire when the next framework revolution comes along. It compounds, making you a better web developer regardless of what tools you use next. That's the difference between building on the web platform and building around it.

    circle-info

    Want to see this philosophy in action? Check out our comprehensive guide on to learn how Aurelia enhances native web APIs like Fetch, Intersection Observer, Geolocation, and many more.

    hashtag
    Convention Over Configuration

    Stop making the same decisions over and over.

    Here's a thought experiment: How many different ways can you structure a web application? How many reasonable approaches are there to naming components, organizing files, or wiring up dependencies?

    The answer is: a few good ways, and hundreds of terrible ways.

    Yet modern web development has become obsessed with giving you infinite configuration options. Choose your file naming strategy from seventeen possibilities. Configure your binding syntax from forty-three variants. Set up your directory structure using our flexible, powerful, completely overwhelming configuration system. Make four hundred decisions before you can render "Hello World."

    This is madness disguised as flexibility.

    Consider what this means for a team. Every developer has their own preferences. The React developer wants JSX everywhere. The Vue developer prefers template syntax. The Angular developer expects decorators. The result? Endless bike-shedding discussions about tooling choices instead of productive work on actual features.

    Or consider what this means for learning. A new developer doesn't just need to learn the framework. They need to learn your team's specific configuration choices, your particular file organization, your custom naming conventions. Every project becomes a unique snowflake with its own special setup.

    Convention over configuration means we make the boring decisions so you don't have to. Create a file called user-profile.ts and another called user-profile.html, and you automatically get a <user-profile> component. Mark a property with @bindable, and a method called propertyChanged automatically becomes a change callback. Put components in a components folder, put services in a services folder, and everything just works.

    These aren't arbitrary restrictions. They're carefully chosen defaults based on years of experience building real applications. We've seen what works and what doesn't. We've observed the patterns that emerge naturally when teams build maintainable applications. We've codified those patterns into conventions that guide you toward good practices.

    But here's the crucial part: conventions should accelerate you, not constrain you. When you genuinely need something different, when your specific use case demands a different approach, every convention has an escape hatch. Need a custom component name? Use @customElement('custom-name'). Need different binding behavior? Configure it explicitly. Need a non-standard file organization? Override the defaults.

    The key word is "earn." You have to consciously choose to deviate from the convention. You have to be explicit about what you want instead. This creates a natural pressure toward consistency while preserving flexibility for genuine edge cases.

    Some developers hate this philosophy. They see conventions as limitations on their creativity. They want to configure every detail of the framework's behavior. They view our defaults as opinions imposed on their artistic vision.

    We think they're optimizing for the wrong thing. Yes, you could spend three days configuring your perfect setup. You could create a unique file organization that perfectly reflects your mental model. You could customize every aspect of the framework's behavior to match your preferences.

    But why? Your user registration component isn't fundamentally different from everyone else's. Your data binding requirements aren't uniquely artistic. Your architectural needs aren't so special that they require a completely novel approach.

    Time spent on configuration is time not spent on features. Energy devoted to framework setup is energy not devoted to solving user problems. Cognitive load consumed by tooling choices is cognitive load unavailable for business logic.

    Conventions are liberation from meaningless choices. They're shared understanding across teams and projects. They're productivity multipliers that let you focus on what actually matters: building great applications that solve real problems for real people.

    hashtag
    Intuitive Syntax That Reads Like You Think

    Code should express intent, not implementation details.

    Here's a simple test: Show a piece of code to someone who's never seen your framework before. Can they understand what it's trying to accomplish? Or do they need to memorize a dozen framework-specific concepts before the code makes sense?

    Most modern frameworks fail this test spectacularly. Their templates are filled with cryptic symbols, magical directives, and abstract concepts that have no relationship to the underlying intent. A simple list becomes a mystical incantation that only the initiated can decipher.

    Consider this common pattern across different frameworks: You want to show a user's name if they're logged in, and a login button if they're not. This is basic conditional rendering. Something any web developer should be able to understand at a glance.

    In many frameworks, this simple intent gets buried under layers of framework-specific syntax. Special directives with obscure names. Conditional operators that work differently than JavaScript. Template languages that require you to think in framework abstractions rather than your actual problem domain.

    Aurelia takes a different approach. Our syntax reads like natural language: <div if.bind="user.isLoggedIn">Welcome, ${user.name}!</div>. An experienced web developer can look at this and immediately understand what's happening, even if they've never seen Aurelia before. The intent is clear. The behavior is predictable. The syntax maps directly to the concept.

    This isn't an accident. It's the result of obsessive attention to developer experience. We believe that the cognitive load of understanding your framework should be as close to zero as possible. Your mental energy should be devoted to solving business problems, not translating between human intent and framework abstractions.

    The benefits of intuitive syntax extend far beyond personal productivity. When your code reads like natural language, it becomes self-documenting. New team members can understand existing code without extensive framework training. Code reviews focus on business logic rather than framework syntax. Bug reports can be understood by non-technical stakeholders.

    But intuitive syntax is about more than just readability. It's about predictability. When syntax clearly expresses intent, the behavior becomes obvious. There are no hidden side effects, no mysterious edge cases, no framework magic that changes behavior based on context you can't see.

    Consider error handling. In many frameworks, when something goes wrong, the error messages are filled with framework internals. Stack traces point to generated code. Error descriptions use framework jargon that tells you nothing about what actually went wrong in your application.

    Aurelia's errors tell you exactly what went wrong and where. When a binding fails, we tell you which property couldn't be found and in which component. When a lifecycle hook throws an exception, we show you the exact method and component that caused the problem. The error messages use the same vocabulary as your code, not our internal implementation details.

    This philosophy extends to debugging. When you inspect an Aurelia application in browser dev tools, you see your components, your properties, your methods. The framework doesn't hide behind layers of generated code or proxy objects. What you wrote is what you see. What you debug is what you ship.

    The industry has somehow convinced itself that complexity is inevitable. That developer tools must be complicated because the problems they solve are complicated. That you need deep framework knowledge to be productive.

    We reject this premise entirely. Complex problems don't require complex tools. They require powerful tools with simple interfaces. The best frameworks make hard things possible and easy things effortless, all while staying out of your way.

    When your framework's syntax fights against your natural thinking patterns, every day becomes a translation exercise. You know what you want to accomplish, but you have to constantly convert your intent into framework-specific abstractions. This cognitive overhead accumulates over time, making you slower, more error-prone, and ultimately less satisfied with your work.

    Intuitive syntax isn't just about making code easier to write. It's about making development more humane. Your tools should amplify your capabilities, not drain your mental energy. They should feel like natural extensions of your thinking, not obstacles to overcome.

    hashtag
    Built for Testing

    If you can't test it easily, we designed it wrong.

    Aurelia's dependency injection system isn't just for organization. It's what makes testing actually pleasant. Need to test a component that depends on services? Just pass in mocks. Everything is classes and interfaces, which means everything is mockable.

    No elaborate test setup, no framework gymnastics, no "testing utilities" that are more complex than the code you're testing. Our component lifecycle is predictable and hookable. You can test each phase independently, spy on lifecycle methods, and verify that cleanup happens when it should.

    This isn't an accident. We've worked on too many projects where testing was painful, so we made it a first-class concern. If testing something in Aurelia feels hard, that's a bug we want to fix.

    hashtag
    Stability Over the Latest Hotness

    We're boring, and proud of it.

    Framework churn is a disease. Every six months there's a new paradigm that's going to "revolutionize" web development. Everyone rewrites their applications to chase the shiny new thing. Six months later, that thing is deprecated in favor of the next revolution.

    We refuse to participate in this madness.

    There are Aurelia applications running in production today that were built in 2015. Nine years later, they still work. The same patterns, the same APIs, the same mental models. The Aurelia of 2015 is, in many fundamental ways, still the Aurelia of today.

    This is not an accident. It's a philosophical choice.

    While other frameworks have gone through complete rewrites that invalidated entire ecosystems, we've evolved gradually. We've added capabilities without breaking existing ones. We've improved performance without changing programming models.

    This makes us unfashionable. Conferences don't invite us to give talks about the revolutionary new paradigm we've invented. Tech Twitter doesn't buzz about our latest rewrite. We don't get to claim we've "solved" web development with our groundbreaking new approach.

    Fine by us. Your applications work. Your knowledge compounds instead of becoming obsolete. Your teams stay productive instead of spending months learning the new hotness. That's worth more than being trendy.

    hashtag
    Architecture for Growing Applications

    Start simple, scale thoughtfully.

    The web development industry has a scaling problem, but it's not the one you think. The real problem isn't technical. It's architectural. Most frameworks force you into a false choice: build quick and dirty prototypes that collapse under their own weight, or start with enterprise-grade complexity that crushes productivity from day one.

    This is a failure of imagination, not engineering.

    Real applications don't start complex. They start simple and become complex over time as requirements evolve, user needs change, and business logic accumulates. The framework you choose should support this natural progression, not fight against it.

    Too many frameworks optimize for one end of the spectrum. The trendy ones make demos look effortless but leave you stranded when you need real architecture. The enterprise ones handle massive complexity beautifully but make simple things unnecessarily complicated. You're forced to choose between fast starts and sustainable growth.

    Aurelia refuses this false choice. We designed every system to scale gracefully from simple to sophisticated without forcing you to rewrite your mental model or refactor your foundation.

    Consider dependency injection. In a small Aurelia application, you might start with simple classes and direct instantiation. As your application grows and testing becomes important, you naturally introduce interfaces and constructor injection. When complexity demands it, you add scoped instances, factory patterns, and custom resolution strategies. Each step builds on the previous one. Your early decisions remain valid even as your architecture evolves.

    The component system works the same way. Start with simple, self-contained components that handle their own data and logic. As requirements grow, introduce shared services, component communication patterns, and hierarchical state management. The simple components don't become wrong or need rewriting. They just become part of a larger, more sophisticated system.

    Our conventions support this progression naturally. The file-based naming that works for a dozen components still works for hundreds. The lifecycle methods that handle simple initialization also handle complex orchestration. The binding patterns that manage basic data flow scale to handle intricate component interactions.

    This isn't theoretical scaling. We've seen applications grow from weekend prototypes to enterprise systems serving millions of users. The fundamental patterns remain consistent. The code written in the early days doesn't become technical debt that needs to be paid down. It becomes the foundation that everything else builds on.

    Compare this to frameworks that require architectural rewrites at different scales. Start with simple state management, then throw it away for a "proper" state solution. Begin with basic routing, then replace it with enterprise routing when you need features. Use simple components initially, then refactor everything when you need composition patterns.

    This constant rewriting isn't progress. It's waste. It's time spent fighting your tools instead of building features. It's knowledge that expires instead of compounds. It's technical debt disguised as best practices.

    We've watched too many projects hit the scaling wall and collapse under their own success. They start fast with a framework that makes demos look effortless. Six months later, they're drowning in unmanageable code, competing patterns, and architectural inconsistencies. The very framework that gave them early velocity becomes the bottleneck that prevents growth.

    We've also seen projects start with enterprise-grade complexity from day one. Multiple abstraction layers, sophisticated patterns, elaborate architectures. Six months later, they're still building infrastructure instead of features. The complexity that was supposed to enable scale becomes the burden that prevents delivery.

    Aurelia's approach is different. The patterns that work for small applications are the same patterns that work for large applications, just applied at different scales. Dependency injection scales from simple constructor parameters to complex service hierarchies. Component composition scales from basic parent-child relationships to sophisticated architectural patterns. Convention-based structure scales from individual files to team-based module organization.

    This consistency has profound implications for team productivity. New developers don't need to learn different patterns as the application grows. Senior developers don't need to constantly re-architect as requirements evolve. The mental model that serves you well early in the project continues serving you well as the project matures.

    Your testing strategies scale the same way. The mocking patterns that work for simple components work for complex service interactions. The lifecycle testing that validates basic behavior also validates sophisticated orchestration. The integration tests that cover simple workflows extend naturally to cover complex business processes.

    Your debugging experience remains consistent as complexity grows. The error messages that help you understand simple binding issues also help you understand complex dependency resolution problems. The development tools that illuminate basic component behavior continue working as your component hierarchies become more sophisticated.

    The industry has convinced itself that scaling requires fundamental architectural changes. That simple patterns must be abandoned for complex ones. That early code must be rewritten for production use.

    We believe that well-designed simple patterns naturally evolve into well-designed complex patterns. That the best foundation for a large application is a small application that grew thoughtfully. That architectural consistency across scales is more valuable than perfect optimization at any individual scale.

    When you build with Aurelia, you're not just building an application. You're growing an architecture. The decisions you make early don't constrain your future options. They create a foundation that supports whatever your application becomes.

    Start simple. Scale thoughtfully. Never rewrite your foundation. That's how real applications get built.

    hashtag
    Popular Is Overrated

    We don't chase GitHub stars. We chase solutions.

    Let's be honest about where we stand. We're not the popular choice. We don't have billion-dollar tech giants throwing marketing budgets at us. We don't have armies of developer advocates making conference circuit rounds. We don't have influencers creating viral videos about the Aurelia revolution.

    React has more GitHub stars than we'll ever see. Vue gets more downloads in a day than we get in a month. Angular dominates job postings and Stack Overflow questions.

    And we're completely fine with that.

    Popularity is a lagging indicator, not a leading one. It tells you what was trendy yesterday, not what will solve your problems tomorrow. The most popular framework isn't necessarily the best framework for your specific needs. It's just the one with the most mindshare at this particular moment.

    We've been around long enough to watch the cycles. Technologies rise and fall based on hype as much as merit. Yesterday's revolutionary breakthrough becomes tomorrow's legacy system. The graveyard of web development is full of frameworks that were once the absolute hottest thing in tech.

    While the popular frameworks optimize for conference buzz and Twitter engagement, we optimize for applications that work reliably over years, not months. We worry about whether our abstractions will make sense to your team three years from now, not whether they'll trend on Hacker News next week.

    The tech industry loves its popularity contests. Industry analysts write reports about the "big three" and mention us as a footnote, if at all. Bootcamps teach the frameworks that help graduates get hired fastest, not necessarily the ones that will serve them best in the long run.

    That's the game, and we understand it. But we're playing a different game entirely.

    We're optimizing for developers who value substance over signals. For teams who care more about shipping reliable software than using the latest hotness. For organizations who measure success by user satisfaction, not developer satisfaction surveys.

    The core team doesn't maintain Aurelia as a side project or resume builder. We stake our professional reputations on it. We bet our careers on it. We build production applications with it every day. When we make a design mistake, we live with it in our own codebases. When we get something right, we benefit directly. Our incentives are perfectly aligned with yours.

    Being the unpopular choice gives us something invaluable: complete intellectual freedom. We don't have to pretend every new JavaScript trend is revolutionary. We don't have to generate artificial excitement to satisfy venture capitalists. We don't have to compromise our engineering principles to chase market share.

    We can afford to be boring when boring is more reliable. We can afford to be unfashionable when unfashionable is more sustainable. We can afford to be right instead of popular.

    The frameworks that are popular today won't necessarily be popular tomorrow. There will be new paradigms, new solutions, new ways of thinking about web development. The cycle will continue, as it always has.

    But the applications built with Aurelia will keep running. The teams using Aurelia will keep shipping. The problems solved with Aurelia will stay solved.

    Because quality outlasts popularity. Substance outlasts hype. Reliability outlasts trendiness.

    We're not trying to win a popularity contest. We're trying to build the most thoughtful, sustainable, and useful framework we can. For developers and teams who share those values.

    That's exactly the position we want to be in.

    hashtag
    We Trust You With Power Tools

    No training wheels, no safety scissors.

    Most frameworks treat you like you can't be trusted with real power. They give you a carefully curated set of approved patterns and lock everything else behind "here be dragons" warnings. They make architectural decisions for you and provide no way to change them. They assume you'll hurt yourself if given too much control.

    We think that's insulting to your intelligence.

    Aurelia is designed around a radical premise: you know what you're doing. You understand your requirements better than we do. You should have the power to make your own architectural decisions, even if we wouldn't make the same ones.

    Don't like our default binding syntax? Configure it. Want custom template behaviors? Build them. Need different binding modes? Create them. The templating and binding systems are designed for extensibility at every level.

    This isn't theoretical power. It's real, practical extensibility that teams use in production applications. We've seen developers create custom binding behaviors that handle complex scenarios we never anticipated. We've seen teams build specialized value converters that transform data in domain-specific ways. We've seen companies extend the templating system with custom attributes that encapsulate their business logic.

    But here's the beautiful part: you don't need any of this complexity to get started. Aurelia works perfectly out of the box without any configuration. Most teams never need to replace a single component. The power is there when you need it, invisible when you don't.

    Compare this to frameworks that make you choose between "simple but limited" and "powerful but complex." We give you simple AND powerful. The default experience just works. The advanced capabilities are there when your requirements demand them.

    Need direct DOM access? Take it. The framework won't fight you or wrap everything in virtual abstractions. Need to hook into the component lifecycle at a granular level? Every lifecycle method is available and predictable. Need to customize how dependency injection works? The entire container is configurable.

    Want to integrate a third-party library that expects to own part of the DOM? Go ahead. Aurelia won't interfere with your jQuery plugins, your D3 visualizations, or your WebGL canvases. We give you escape hatches everywhere because we know real applications have messy requirements.

    Our dependency injection system exemplifies this philosophy. Out of the box, it handles constructor injection with sensible defaults. But if you need custom resolution strategies, scoped instances, factory functions, or completely custom behaviors, the system is flexible enough to handle it all.

    The templating system works the same way. The default syntax handles 95% of use cases elegantly. But when you need custom binding behaviors, specialized value converters, or completely novel template constructs, the architecture supports it without forcing you to fight the framework.

    Yes, this means you can hurt yourself. You can create circular dependencies that crash at runtime. You can bind to expensive computations that tank performance. You can write components that leak memory all over the place. You can architect systems that are impossible to maintain.

    We're not going to stop you. We're also not going to assume you're incompetent enough to do these things accidentally.

    The industry trend is toward frameworks that make dangerous things impossible. Every API is locked down. Every extension point is carefully controlled. Every architectural decision is made for you "for your own good."

    We prefer frameworks that make powerful things possible. We trust you to understand the trade-offs. We trust you to test your code. We trust you to make responsible decisions about performance and maintainability.

    This philosophy extends to our error handling. When something goes wrong, we don't hide it behind friendly abstractions. We show you exactly what failed, where it failed, and why it failed. Our error messages assume you're capable of understanding technical details and fixing the underlying problem.

    Other frameworks optimize for protecting developers from themselves. We optimize for empowering developers to solve their problems. Sometimes those problems require sharp tools, direct access, and the freedom to make unconventional choices.

    The difference is trust. We trust that you're a professional who can handle professional tools. We trust that you'll read the documentation, understand the implications, and make informed decisions about your architecture.

    When you need to do something unusual, something the framework designers never anticipated, you shouldn't have to fight your tools or work around artificial limitations. You should be able to extend, customize, and override as needed.

    That's what real power looks like. Not just the ability to configure options, but the ability to fundamentally change how the framework behaves when your requirements demand it.

    The training wheels come off. The safety scissors get put away. We hand you the real tools and trust you to build something amazing.

    hashtag
    Complete, Not Cobbled Together

    Everything works together because we designed it that way.

    Modern web development has become an exercise in integration hell. Install seventeen npm packages with incompatible APIs. Configure twenty-three build tools that don't know about each other. Wire together forty-nine loosely related libraries that were never designed to work as a system. Then spend three days debugging version conflicts, peer dependency warnings, and mysterious build failures.

    This is insanity disguised as flexibility.

    The JavaScript ecosystem's obsession with modularity has created a paradox: in trying to make everything composable, we've made nothing actually compose well. Every package is an island. Every library has its own conventions, its own configuration format, its own way of handling errors. Integrating them requires endless glue code, adapter layers, and configuration files that exist solely to make incompatible things pretend to work together.

    Aurelia takes a radically different approach. We're a complete system designed as a complete system from day one.

    Our router doesn't just handle navigation. It understands our component lifecycle, integrates with our dependency injection system, and works seamlessly with our templating engine. When you navigate to a route, the router knows how to instantiate components with their dependencies, bind their properties, and manage their lifecycle hooks. No adapters, no glue code, no configuration mapping.

    Our templating system doesn't exist in isolation. It's deeply integrated with our binding engine, our component system, and our validation framework. When you bind to a property in a template, the system understands the component's lifecycle, respects the validation rules, and integrates with the change detection system. Everything flows together naturally.

    Our testing utilities aren't afterthoughts built by the community. They're designed specifically for Aurelia's architecture. They understand our dependency injection system, so mocking is trivial. They know our component lifecycle, so testing different phases is straightforward. They integrate with our templating system, so testing complex UI interactions doesn't require elaborate setup.

    This integration goes deeper than just APIs that work well together. Our error messages are consistent across all systems because they're built by the same team with the same philosophy. Our debugging experience is coherent because all the pieces understand each other. Our performance optimizations work across the entire stack because we control the entire stack.

    Compare this to the typical modern web application. You have a routing library that knows nothing about your state management solution. A component framework that's unaware of your validation library. A testing framework that requires custom adapters to work with your template syntax. Each piece is excellent in isolation, but together they create a fragmented experience.

    When something goes wrong in a cobbled-together system, good luck figuring out where. Is it the router? The state manager? The component library? The build tool? The integration layer between two of them? You'll spend more time debugging the interactions between your tools than the actual business logic they're supposed to support.

    When something goes wrong in Aurelia, the error messages tell you exactly what happened and where. The stack traces point to your code, not framework internals or integration adapters. The debugging experience is consistent because there's one coherent system, not a collection of independent libraries pretending to be friends.

    This approach has real costs. We can't always use the "best of breed" solution for every individual piece. Our router might not have every feature of the most advanced standalone routing library. Our state management might not be as sophisticated as the latest state management trend. We have to build and maintain more code instead of delegating to the community.

    But the benefits are transformative. You spend your time building features, not integrating tools. You debug your application logic, not framework compatibility issues. You learn one coherent system instead of a dozen different libraries with conflicting philosophies.

    Your team onboards faster because there's one consistent way of doing things across the entire application. Your application performs better because all the pieces are optimized to work together. Your codebase is more maintainable because there are fewer integration layers and compatibility shims.

    When you need to add a new feature, you're working with the framework, not against a collection of competing libraries. When you need to debug a problem, you're working within one system with consistent patterns, not trying to understand the interaction between multiple independent systems.

    The industry has convinced itself that maximum modularity leads to maximum flexibility. That the best system is one assembled from the best individual components. That integration is someone else's problem.

    We believe integration is the most important problem. That a complete system designed to work together will always be more reliable than a collection of perfect pieces that weren't designed for each other.

    We built Aurelia as a complete, integrated solution because that's what real applications need. Not maximum theoretical flexibility, but maximum practical reliability. Not the coolest individual components, but the most coherent overall experience.

    You don't have to become an expert in seventeen different libraries just to build a web application. You don't have to spend weeks researching compatibility matrices and integration guides. You don't have to maintain a tower of adapters and glue code that adds complexity without adding value.

    You just build your application. The framework handles the rest.

    hashtag
    No Surprises

    What you see is what you get.

    Framework magic is a disease. Hidden behaviors, implicit conventions that change based on context, APIs that behave differently depending on what phase of the moon it is. Too many frameworks treat mystery as a feature.

    Aurelia is boringly predictable.

    When you bind to a property, it binds to that property. When you trigger an event, it triggers that event. When you inject a dependency, you get that dependency. The behavior you see is the behavior you get.

    Our error messages tell you exactly what went wrong and how to fix it. Our lifecycle hooks run in the order you'd expect. Our binding system does what the syntax suggests it does.

    This predictability isn't an accident. It's a core design principle. Complex applications require dependable foundations. When your framework behaves surprisingly, everything built on top of it becomes fragile.

    Boring reliability beats clever unpredictability every time.

    hashtag
    Progress Through Evolution, Not Revolution

    We improve by addition, not destruction.

    The tech industry is obsessed with dramatic rewrites. Every few years, someone declares that everything we've learned is wrong and we need to start over from scratch. New paradigms emerge that invalidate entire ecosystems. Frameworks abandon their users in pursuit of architectural purity.

    This is progress theater, not actual progress.

    Real progress preserves what works while improving what doesn't. It builds on existing knowledge instead of discarding it. It respects the investments people have made in learning your platform, building their applications, and training their teams.

    Too many frameworks treat major versions like clean slates. They change fundamental concepts, abandon proven patterns, and force you to relearn everything. The justification is always the same: "We had to break things to make them better."

    We believe progress and stability aren't opposites. They're requirements that must be balanced.

    Consider how we approached Aurelia 2. We rebuilt the framework's internals for dramatically better performance. We redesigned the binding system for more predictable behavior. We modernized the architecture to support emerging web standards. We fixed years of accumulated design debt.

    But we didn't throw away what worked.

    If you know how to build an Aurelia 1 application, you know how to build an Aurelia 2 application. The same conventions work. The same mental models apply. The same patterns scale. Your components are still classes with lifecycle methods. Your templates still use the same binding syntax. Your dependency injection still works the same way.

    We made the performance improvements invisible to your application code. We enhanced the binding system without changing its behavior from your perspective. We modernized the architecture while preserving the programming model you'd learned.

    Yes, some things changed. Some APIs became more consistent. Some edge cases were fixed. Some deprecated features were finally removed. But the core experience of building with Aurelia remained fundamentally the same.

    This approach has costs. We couldn't make radical performance improvements that would have required completely different programming models. We couldn't chase architectural trends that would have invalidated existing knowledge. We had to think carefully about every change because we knew people were depending on the current behavior.

    But this approach also has benefits. Your Aurelia 1 knowledge transferred directly to Aurelia 2. Your team didn't need months of retraining. Your migration path was evolutionary, not revolutionary. Your investment in the platform continued paying dividends.

    When other frameworks release major versions, teams often decide it's easier to rewrite their applications than migrate them. When we released Aurelia 2, teams migrated because it was the obvious next step, not a leap into the unknown.

    Progress without preservation is just churn. We choose sustainable progress over revolutionary change because we're building for the long term. Your applications deserve better than constant rewrites. Your teams deserve better than endless relearning. Your users deserve better than instability disguised as innovation.


    These aren't just marketing principles. They're the beliefs that shaped every architectural decision, every API design, and every line of documentation. When you choose Aurelia, you're choosing more than a framework. You're choosing an approach to web development that values your time, respects your intelligence, and bets on the long-term health of the web platform.

    Whether you're building your first application or your hundredth, whether you're working solo or on a team of dozens, whether you're prototyping or preparing for production, Aurelia is designed to meet you where you are and grow with you where you're going.

    This is why we build.

    Templates Overview & Quick Reference

    Welcome to the Aurelia templating documentation! This guide covers everything you need to build dynamic, interactive UIs with Aurelia's powerful templating system.

    hashtag
    Quick Start

    New to Aurelia templates? Start here:

    1. Cheat Sheet - Quick syntax reference for all template features

    2. - Core concepts and features

    3. - Complete working examples

    hashtag
    How Do I...?

    Find what you need quickly with this task-based guide:

    hashtag
    Display Data

    • Show dynamic text? → - Use ${property}

    • Bind to element properties? → - Use .bind, .to-view, .two-way

    hashtag
    Work with Lists

    • Loop through an array? → - Use repeat.for="item of items"

    • Get the current index? → - Use $index, $first, $last

    hashtag
    Handle User Input

    • Capture button clicks? → - Use click.trigger="method()"

    • Handle keyboard input? → - Use keydown.trigger:enter="submit()"

    • Create two-way form bindings? → - Use value.bind="property"

    hashtag
    Conditional Logic

    • Show content based on a condition? →

    • Toggle visibility frequently? →

    • Handle multiple conditions? →

    hashtag
    Styling

    • Apply dynamic CSS classes? →

    • Bind inline styles? →

    • Toggle classes conditionally? →

    hashtag
    Components

    • Use a component in my template? → - Use <import from="./my-component"></import>

    • Make a component available globally? →

    • Pass data to a component? → - Use @bindable and bind in parent

    hashtag
    Forms

    • Build a form? →

    • Validate form input? →

    • Handle form submission? →

    hashtag
    Advanced

    • Create reusable template behaviors? →

    • Transform data in templates? →

    • Control binding behavior? →

    hashtag
    Documentation Structure

    hashtag
    Core Concepts

    • - Start here for the big picture

    • - Quick reference for all syntax

    hashtag
    Template Syntax

    • - Display data with ${}

    • - Bind to element properties and attributes

    • - Handle user interactions

    hashtag
    Display Logic

    • - Show/hide content with if, show, switch

    • - Loop over data with repeat.for

    hashtag
    Data Transformation

    • - Format and transform data (like pipes)

    • - Control binding flow (debounce, throttle, etc.)

    hashtag
    Forms & Input

    • - Working with form inputs

    • - Checkboxes, radios, multi-select

    • - Submit forms and handle user feedback

    hashtag
    Extensibility

    • - Create reusable template behaviors

    • - Complex attribute patterns

    hashtag
    Other Features

    • - Arrow functions in templates

    • - Inline component definitions

    • - Working with SVG elements

    hashtag
    Real-World Examples

    • - Complete, production-ready examples

      • - Search, filter, sort

      • - Add/remove items, calculate totals

    hashtag
    Learning Path

    Not sure where to start? Follow this path:

    hashtag
    Beginner

    1. Read

    2. Learn and

    3. Try for interactivity

    4. Practice with

    hashtag
    Intermediate

    1. Master (if, show, switch)

    2. Learn (repeat.for)

    hashtag
    Advanced

    1. Create

    2. Use for fine control

    3. Work with

    4. Explore

    hashtag
    Performance Tips

    • Use .to-view binding for display-only data

    • Add key to repeat.for for dynamic lists

    • Use show.bind

    hashtag
    Common Pitfalls

    • Components not appearing? → Don't forget <import from="./component"></import> (or register globally)

    • Array changes not detected? → Use array methods like push(), splice(), not direct index assignment

    hashtag
    Need Help?

    • Check the for quick syntax reference

    • Browse for complete examples

    • Review for core concepts

    hashtag
    Related Documentation

    • - Build reusable UI components

    • - Core Aurelia concepts

    • - Navigation and routing

    Component lifecycles

    Aurelia components offer a rich lifecycle that lets you hook into specific moments of a component's existence—from construction, through activation, to eventual disposal. Understanding the order and intent of each hook will help you write components that are predictable, testable, and memory-leak-free.

    circle-info

    All lifecycle callbacks are optional. Implement only what you need. Hooks such as binding/unbinding or attaching/detaching are often implemented in pairs so you can clean up resources you set up in the first hook.

    circle-info

    Lifecycle hooks apply to custom elements and custom attributes. Synthetic views (created by template controllers like if, repeat) do not have lifecycle hooks, but their child components do.

    hashtag
    Quick reference

    Phase
    Hook
    Runs
    Child-parent order
    Async?

    Legend

    • top ➞ down – parent executes before its children

    • bottom ➞ up – children execute before their parent

    hashtag
    Detailed walkthrough

    hashtag
    1. Constructor

    Executed when the instance is created. Inject services here and perform work that does not depend on bindable values.

    hashtag
    2. Define

    • Opportunity to modify the component definition before hydration begins.

    • Can return a partial definition to override aspects of the component's behavior.

    • Runs synchronously, parent before children.

    hashtag
    3. Hydrating

    • Opportunity to register dependencies in controller.container that are needed while compiling the view template.

    • Runs synchronously, parent before children.

    hashtag
    4. Hydrated

    • View template has been compiled, child components are not yet created.

    • Last chance to influence how the soon-to-be-created child components resolve their dependencies.

    hashtag
    5. Created

    • All child components are now constructed and hydrated.

    • Executes once per instance, children before parent.

    • Great for logic that must run after the whole subtree is constructed but before binding.

    hashtag
    6. Binding

    • Bindable properties have been set but bindings in the view are not yet connected.

    • Runs parent ➞ child.

    • Return a Promise (or mark the method async) to block binding/attaching of children until resolved.

    hashtag
    7. Bound

    • View-to-view-model bindings are active; ref, let, and from-view values are available.

    • Executes child ➞ parent.

    hashtag
    8. Attaching

    • The component's host element is now in the DOM but child components may still be attaching.

    • Queue animations or setup 3rd-party libraries here.

    • A returned Promise is awaited before attached is invoked on this component but does not block children

    hashtag
    9. Attached

    • The entire component subtree is mounted; safe to measure elements or call libraries that need actual layout information.

    • Executes child ➞ parent.

    • Note: Only receives the initiator parameter, not the parent.

    hashtag
    10. Detaching

    • Called when the framework removes the component's element from the DOM.

    • Executes child ➞ parent. Any returned Promise (e.g., an outgoing animation) is awaited in parallel with sibling promises.

    hashtag
    11. Unbinding

    • Runs after detaching finishes and bindings have been disconnected.

    • Executes child ➞ parent.

    hashtag
    12. Dispose

    • Invoked when the instance is permanently discarded—typically when removed from a repeater and the view cache is full, or when the application shuts down.

    • Use to tear down long-lived resources, subscriptions, or manual observers to prevent memory leaks.

    hashtag
    Lifecycle hooks decorator (@lifecycleHooks)

    For cross-cutting concerns like logging, analytics, or debugging, implement lifecycle hooks in a separate class using the @lifecycleHooks decorator. This keeps your component code focused while adding shared behavior.

    Multiple lifecycle hook classes can be registered; the framework executes them in registration order alongside the component's own lifecycle methods.

    hashtag
    Special cases

    • <au-compose> components additionally support activate / deactivate hooks—see the .

    • Router hooks such as canLoad, loading, canUnload,

    hashtag
    Best practices

    1. Prefer early exits—perform checks at the start of hooks and return early to minimise nesting.

    2. Clean up observers, timeouts, event listeners, or 3rd-party widgets in the opposite hook (unbinding/detaching or dispose).

    Enhance

    Learn how to use Aurelia's enhance feature to add interactivity to existing HTML, integrate with other frameworks, hydrate server-rendered content, and create multiple Aurelia instances in your applic

    hashtag
    What is Enhancement?

    Enhancement allows you to bring Aurelia's data binding, templating, and component features to existing DOM content without replacing it entirely. Instead of starting with an empty element and rendering into it, enhancement takes existing HTML and makes it "Aurelia-aware".

    This is perfect for:

    Performance

    memo, useMemo, useCallback

    Automatic optimization

    Styling

    CSS-in-JS or external files

    Automatic CSS loading + Shadow DOM

    Forms

    Controlled components + validation libs

    Two-way binding + built-in validation

    Working with Web Standards
    Format data for display? → Value Converters - Use ${value | converter}
  • Show/hide elements? → Conditional Rendering - Use if.bind or show.bind

  • Handle empty lists? → Conditional Rendering - Combine if.bind="items.length === 0" with else
  • Optimize large lists? → List Rendering: Performance - Use key.bind or key:

  • Prevent default behavior? → Event Binding: Modifiers - Use click.trigger:prevent="method()"

  • Debounce rapid input? → Event Binding: Binding Behaviors - Use input.trigger="search() & debounce:300"

  • Show if/else branches? → Conditional Rendering: else - Use else after if.bind

    Get a reference to an element? → Template References - Use ref="elementName"

  • Slot content into a component? → Slotted Content

  • Work with checkboxes? → Forms: Collections
  • Handle file uploads? → Forms: File Uploads

  • Work with promises? → Template Promises - Use promise.bind
  • Create local template variables? → Template Variables - Use <let>

  • Change binding context for a section? → with.bind

  • Bind element focus state? → focus - Use focus.bind

  • Spread config objects / forward captured attrs? → Spread operators - Use ...$bindables / ...$attrs

  • Render markup elsewhere in the DOM? → Portalling elements - Use portal

  • Compose components dynamically? → Dynamic composition - Use <au-compose>

  • Work with SVG? → SVG

  • Use lambda expressions? → Lambda Expressions

  • Template References - Access DOM elements with ref
  • Template Variables - Create local variables with <let>

  • with.bind - Re-scope a section to an object

  • Template Promises - Handle async data with promise.bind

  • Spread operators - Bindables spreading and attribute transferring

  • Globals - Built-in global functions and values

  • Class & Style Bindings - Dynamic CSS
    - Handle file inputs and uploads
  • Validation Plugin - Validate user input

  • More coming soon—follow the contribution guide in the recipes index to share yours!
    Explore Value Converters for data formatting
  • Build forms with Forms Guide

  • Try Shopping Cart Recipe

  • Adapt real-world patterns from the Template Recipes collection

    for frequent visibility toggles
  • Use if.bind for infrequent changes

  • Debounce rapid input events

  • Keep expressions simple - move logic to view model

  • Form input not updating? → Use .bind or .two-way, not .to-view
  • Performance issues with large lists? → Add key.bind or key: to repeat.for

  • Bindings not working? → Check for typos in property names and binding commands

  • Search the "How Do I...?" section above
  • Visit Aurelia Discoursearrow-up-right for community support

  • Check GitHub Issuesarrow-up-right for known issues

  • - Share services between components
    Template Syntax Overview
    Real-World Recipes
    Text Interpolation
    Attribute Binding
    List Rendering
    List Rendering: Contextual Properties
    Event Binding
    Event Binding: Keyboard Events
    Forms
    Conditional Rendering: if.bind
    Conditional Rendering: show.bind
    Conditional Rendering: switch.bind
    Class & Style Binding
    Class & Style Binding
    Class & Style Binding
    Component Basics: Importing
    Component Basics: Global Registration
    Bindable Properties
    Forms: Basic Inputs
    Validation Plugin
    Forms: Submission
    Custom Attributes
    Value Converters
    Binding Behaviors
    Template Syntax Overview
    Cheat Sheet
    Text Interpolation
    Attribute Binding
    Event Binding
    Conditional Rendering
    List Rendering
    Value Converters
    Binding Behaviors
    Forms Overview
    Form Collections
    Form Submission
    Custom Attributes
    Advanced Custom Attributes
    Lambda Expressions
    Local Templates
    SVG
    Recipes
    Product Catalog
    Shopping Cart
    Template Syntax Overview
    Text Interpolation
    Attribute Binding
    Event Binding
    Product Catalog Recipe
    Conditional Rendering
    List Rendering
    Custom Attributes
    Binding Behaviors
    Template Promises
    Advanced Custom Attributes
    Cheat Sheet
    Recipes
    Template Syntax Overview
    Components
    Essentials
    Router
    File Uploads
    Dependency Injection
    Progressive enhancement of server-rendered pages
  • Integration with existing applications or other frameworks

  • Widget development where you control specific sections of a page

  • Content Management Systems where you want to add interactivity to generated content

  • Legacy application modernization done incrementally

  • The startup sections showed how to start Aurelia for empty elements. Enhancement lets you work with existing DOM trees instead.

    Before you start: Review App configuration and startup to understand the standard bootstrap flow; enhancement builds on those concepts.

    hashtag
    Understanding What Gets Enhanced

    When you enhance an element, Aurelia treats that element as if it were the template of a custom element. The existing HTML becomes the "template" and your component object becomes the "view model" that provides data and behavior.

    hashtag
    Basic Enhancement Syntax

    hashtag
    Component Types: Classes, Instances, or Objects

    You can enhance with three different component types:

    hashtag
    Key Enhancement Concepts

    1. Existing DOM is preserved: Enhancement doesn't replace your HTML - it makes it interactive

    2. Existing event handlers remain: Any JavaScript event listeners you've already attached stay functional

    3. Manual lifecycle management: You're responsible for calling deactivate() when done

    4. Template compilation: Aurelia compiles the existing HTML for bindings and directives

    hashtag
    Proper Cleanup

    Always clean up enhanced content to prevent memory leaks:

    hashtag
    Practical Enhancement Examples

    hashtag
    Server-Rendered Content Enhancement

    Suppose your server renders this HTML:

    You can enhance it to make it interactive:

    hashtag
    Widget Integration Example

    Create interactive widgets within existing pages:

    hashtag
    Dynamic Content Enhancement

    Enhancement is perfect for content that gets added to the page after initial load.

    hashtag
    Enhancing Dynamically Loaded Content

    hashtag
    Enhancing Modal or Dialog Content

    hashtag
    Advanced Enhancement Patterns

    hashtag
    Using Custom Containers

    When you need specific services or configurations for enhanced content:

    hashtag
    Lifecycle Hooks in Enhanced Components

    Enhanced components support all standard Aurelia lifecycle hooks:

    hashtag
    Common Enhancement Patterns

    hashtag
    Progressive Enhancement Checklist

    1. Identify enhancement targets: Elements that need interactivity

    2. Preserve existing functionality: Don't break existing event handlers

    3. Plan your data flow: How will data get to enhanced components?

    4. Handle cleanup: Always deactivate when done

    5. Test without JavaScript: Ensure basic functionality works without enhancement

    hashtag
    Best Practices

    • Start small: Enhance specific widgets before entire sections

    • Use meaningful component objects: Include methods and properties that make sense

    • Handle errors gracefully: Enhancement might fail if DOM structure changes

    • Document what gets enhanced: Make it clear to other developers

    • Consider performance: Don't enhance too many elements at once

    hashtag
    When NOT to Use Enhancement

    • New applications: Use regular Aurelia.app() for greenfield projects

    • Full page control: When you control the entire page, standard app startup is simpler

    • Simple static content: If content doesn't need interactivity

    • Performance critical sections: Enhancement has overhead compared to pre-compiled templates

    Enhancement shines when you need to add Aurelia's power to existing content without rebuilding everything from scratch.

    hashtag
    Next steps

    • Use dynamic composition when you need to render different components inside an enhanced region.

    • Combine enhancement with watching data to react to model changes in legacy markup.

    • Explore portalling elements for UI that needs to escape its original DOM location.

    // 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()">&times;</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.
        }
      }
    });

    define

    once

    top ➞ down

    no

    hydrating

    once

    top ➞ down

    no

    hydrated

    once

    top ➞ down

    no

    created

    once

    bottom ➞ up

    no

    Activation

    binding

    every activation

    top ➞ down

    yes (blocks children)

    bound

    every activation

    bottom ➞ up

    yes (awaits)

    attaching

    every activation

    top ➞ down

    yes (awaits before attached)

    attached

    every activation

    bottom ➞ up

    yes (awaits)

    Deactivation

    detaching

    every deactivation

    bottom ➞ up

    yes (awaits before DOM removal)

    unbinding

    every deactivation

    bottom ➞ up

    yes (awaits)

    Cleanup

    dispose

    when permanently discarded

    –

    –

    .
    unloading
    , etc., are documented in the
    and are available even if you do not use the router.

    Avoid heavy work in the constructor. Move anything needing bindables or DOM to later hooks.

  • Mark hooks async and await your operations instead of manually creating Promises for clarity.

  • Keep hooks fast—expensive work can block the component hierarchy.

  • Construction

    constructor

    once

    –

    –

    import { 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`);
      }
    }
    dynamic composition guide

    routing lifecycle section

    @slotted Decorator

    The @slotted decorator provides a declarative way to observe and react to changes in slotted content within your custom elements. This decorator automatically tracks which elements are projected into specific slots and provides your component with an up-to-date array of matching nodes.

    hashtag
    Overview

    When building custom elements that accept slotted content, you often need to:

    • Know which elements were projected into a specific slot

    • Filter projected elements by CSS selector

    • React when the projected content changes

    The @slotted decorator handles all of this automatically, creating a reactive property that updates whenever the slotted content changes.

    hashtag
    Basic Usage

    Usage:

    hashtag
    Filtering with CSS Selectors

    Use a CSS selector to filter which slotted elements are tracked:

    Usage:

    hashtag
    Targeting Specific Slots

    When your component has multiple named slots, you can target specific slots:

    Usage:

    hashtag
    Watching All Slots

    Use '*' as the slot name to watch all slots simultaneously:

    hashtag
    Change Callbacks

    The @slotted decorator automatically looks for a callback method following the naming convention {propertyName}Changed:

    hashtag
    Custom Callback Names

    You can specify a custom callback method name:

    hashtag
    Advanced Configuration

    The @slotted decorator accepts a configuration object for fine-grained control:

    hashtag
    Configuration Options

    Property
    Type
    Default
    Description

    hashtag
    Querying All Nodes Including Text Nodes

    By default, @slotted only tracks element nodes. To include text nodes, use the special query '$all':

    hashtag
    Complex Selectors

    The query parameter accepts any valid CSS selector:

    hashtag
    Complete Example: Dynamic Tab Component

    Here's a comprehensive example showing how to build a tab component using @slotted:

    Usage:

    hashtag
    Subscribing to Changes Programmatically

    The property decorated with @slotted has a special getObserver() method that returns a subscriber collection:

    hashtag
    Lifecycle and Timing

    The @slotted decorator integrates with Aurelia's lifecycle:

    • Activation: The watcher starts observing during the binding lifecycle

    • Deactivation: The watcher stops observing during the unbinding lifecycle

    • Initial Callback: The change callback is invoked after bound

    hashtag
    Comparison with @children Decorator

    Both @slotted and @children decorators watch for changes in child elements, but they serve different purposes:

    Feature
    @slotted
    @children

    Use @slotted when:

    • You're using <au-slot> for content projection

    • You need to track which content was projected into which slot

    • You want to filter projected content by selector

    Use @children when:

    • You need to observe the direct children of your component's host element

    • You're not using slots

    • You need access to child component view models (via the filter and map options)

    hashtag
    Important Notes

    • The @slotted decorator only works with <au-slot>, not native <slot> elements

    • The decorated property becomes read-only; attempting to set it manually has no effect

    • Changes to the slotted content are detected via MutationObserver

    hashtag
    See Also

    • - Overview of slots in Aurelia

    • - Alternative for watching child elements

    • - Building custom elements

    Form Basics

    Forms are the cornerstone of interactive web applications. Whether you're building simple contact forms, complex data-entry systems, or dynamic configuration interfaces, Aurelia provides a comprehensive and performant forms system.

    circle-info

    This guide assumes familiarity with Aurelia's binding system and template syntax. For fundamentals, see Template Syntax & Features first.

    hashtag
    Quick Navigation

    • - Text, textarea, number, date inputs

    • - Checkboxes, radios, multi-select, arrays

    • - Submit forms, handle events

    hashtag
    Understanding Aurelia's Form Architecture

    Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls.

    hashtag
    Data Flow Architecture

    Key Components:

    1. Observers: Monitor DOM events and property changes

    2. Bindings: Connect observers to view model properties

    3. Collection Observers: Handle arrays, Sets, and Maps efficiently

    hashtag
    Automatic Change Detection

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.

    hashtag
    Basic Input Binding

    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals.

    hashtag
    Simple Text Inputs

    The foundation of most forms is text input binding:

    Key points:

    • Use value.bind for two-way binding

    • Form inputs default to two-way binding automatically

    • Computed properties (like isFormValid) automatically update

    hashtag
    Textarea Binding

    Textareas work identically to text inputs:

    hashtag
    Number and Date Inputs

    Browser form controls always provide string values unless you bind to their typed DOM properties. Use Aurelia's value-as-* bindings when you need numbers or dates in your view-model.

    value-as-number binds to the input's valueAsNumber, so age is a number (or NaN when the field is empty/invalid). value-as-date binds to valueAsDate, giving you a Date | null. If you keep value.bind, the value remains a string—convert it before serializing to JSON for APIs.

    hashtag
    Input Types and Binding

    Aurelia supports all HTML5 input types:

    Type
    Value
    Common Use

    hashtag
    Binding Modes

    While value.bind is automatic two-way binding, you can be explicit:

    When to use each:

    • .bind - Default, use for most form inputs

    • .two-way - Explicit two-way, same as .bind for inputs

    • .to-view

    hashtag
    Real-World Example

    Here's a complete user registration form:

    hashtag
    Next Steps

    Now that you understand basic form inputs, explore:

    • - Checkboxes, radio buttons, and multi-select

    • - Integrate form validation

    • - Handle file inputs

    hashtag
    Quick Tips

    1. Always use labels - Associate labels with inputs using for attribute

    2. Validate on submit - Don't validate every keystroke unless needed

    3. Provide feedback - Show errors clearly after user completes input

    hashtag
    Common Pitfalls

    • Forgetting .bind - Must use .bind for two-way binding

    • Type mismatches - Number inputs return strings, convert if needed

    • Direct object mutation - Use this.form.prop = value

    hashtag
    Related Documentation

    Extending the template compiler

    The Aurelia template compiler is highly extensible, providing multiple hooks and extension points for advanced customization. This guide covers the advanced features and extension mechanisms available for developers who need to extend template compilation behavior.

    hashtag
    Template Compiler Hooks

    hashtag
    Registering Compilation Hooks

    Template compiler hooks allow you to modify templates during the compilation process:

    hashtag
    Global vs Component-Level Hooks

    Hooks can be registered globally or at the component level:

    hashtag
    Advanced Attribute Pattern System

    hashtag
    Creating Custom Attribute Syntax

    The attribute pattern system allows you to create custom binding syntax:

    hashtag
    Complex Pattern Matching

    Support for multi-part patterns with custom symbols:

    hashtag
    Custom Binding Commands

    hashtag
    Advanced Binding Command Features

    Binding commands can take full control of attribute processing:

    hashtag
    Multi-Attribute Processing

    Commands can process multiple attributes for complex scenarios:

    hashtag
    Template Element Factory Customization

    hashtag
    Custom Template Caching

    The template element factory supports custom caching strategies:

    hashtag
    Template Wrapping Detection

    Customize how templates are wrapped for proper compilation:

    hashtag
    Advanced Resource Resolution

    hashtag
    Custom Resource Discovery

    Implement custom resource resolution for dynamic components:

    hashtag
    Bindables Information Caching

    Optimize bindable resolution with custom caching:

    hashtag
    Local Template System

    hashtag
    Advanced Local Element Definitions

    Create complex local element hierarchies:

    hashtag
    Dynamic Local Template Creation

    Create local templates programmatically:

    hashtag
    Compilation Context System

    hashtag
    Hierarchical Resource Resolution

    Work with compilation contexts for advanced scenarios:

    hashtag
    Custom Dependency Injection

    Customize DI container behavior during compilation:

    hashtag
    Performance Optimization

    hashtag
    Template Compilation Caching

    Implement aggressive template caching for performance:

    hashtag
    Compilation Mode Optimization

    Configure compilation for different environments:

    hashtag
    Best Practices

    hashtag
    1. Hook Registration

    • Register global hooks early in application bootstrap

    • Use component-level hooks for specific customizations

    • Keep hooks lightweight to avoid compilation performance impact

    hashtag
    2. Pattern and Command Design

    • Design patterns to be intuitive and consistent with Aurelia conventions

    • Use descriptive names and clear syntax

    • Provide good error messages for invalid usage

    hashtag
    3. Resource Resolution

    • Cache expensive resource lookups

    • Implement fallback mechanisms for missing resources

    • Use lazy loading for dynamic components

    hashtag
    4. Performance Considerations

    • Profile template compilation in development

    • Use AOT compilation for production builds

    • Implement smart caching strategies

    • Monitor memory usage with large template caches

    hashtag
    5. Testing Extensions

    • Create unit tests for custom hooks and commands

    • Test compilation output for correctness

    • Verify performance impact of extensions

    • Test edge cases and error handling

    The template compiler's extensibility allows for powerful customizations while maintaining framework performance and consistency. Use these extension points judiciously to enhance your application's template processing capabilities.

    Lambda Expressions

    Master lambda expressions in Aurelia templates to write cleaner, more expressive code. Learn the supported syntax, array operations, event handling, and performance considerations with real examples f

    Lambda expressions in Aurelia templates allow you to use arrow function syntax directly in your HTML bindings. This feature enables inline data transformations, filtering, and event handling without requiring additional methods in your view models.

    hashtag
    Table of Contents

    Component basics

    Components are the building blocks of Aurelia applications. This guide covers creating, configuring, and using components effectively.

    Components are the core building blocks of Aurelia applications. Each component typically consists of:

    • A TypeScript class (view model)

    • An HTML template (view)

    Accordion

    Build an accessible accordion component with smooth animations and keyboard support

    Learn to build a simple yet powerful accordion component for collapsible content panels. Perfect for FAQs, settings panels, and content organization.

    hashtag
    What We're Building

    An accordion that supports:

    https://stackblitz.com/edit/au2-promise-binding-using-functions-improved?ctl=1&embed=1&file=src/my-app.tsstackblitz.comchevron-right

    Name of the slot to watch. Use '*' to watch all slots

    callback

    PropertyKey

    '{property}Changed'

    Name of the callback method to invoke when slotted content changes

    with the initial slotted elements
  • Updates: The callback is invoked whenever slotted content changes (elements added, removed, or reordered)

  • CSS selector + slot name

    CSS selector only

    Tracks

    Content from parent component

    Direct children only

    Best for

    Content projection scenarios

    Observing component's immediate children

    , so deep changes within slotted elements aren't automatically detected
  • The query selector is evaluated against each slotted node; complex selectors may impact performance with many slotted elements

  • - Component lifecycle integration

    query

    string

    '*'

    CSS selector to filter slotted elements. Use '*' to match all elements, '$all' to include text nodes

    slotName

    string

    Purpose

    Watch slotted content projected into <au-slot>

    Watch direct child elements of the host

    Use with

    Shadow DOM or <au-slot> components

    Any component

    Slotted Content
    @children Decorator
    Custom Elements

    'default'

    Filters by

    Lifecycle Hooks
    - Handle file inputs and uploads
  • Validation Plugin - Integrate with @aurelia/validation

  • Mutation Observers: Track dynamic DOM changes
  • Value Converters & Binding Behaviors: Transform and control data flow

  • Select elements: change events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • string

    Password fields

    number

    string (use value-as-number for number)

    Numeric input

    tel

    string

    Phone numbers

    url

    string

    Website URLs

    search

    string

    Search queries

    date

    string (use value-as-date for Date)

    Date selection

    time

    string

    Time selection

    datetime-local

    string (use value-as-date for Date)

    Date and time

    color

    string

    Color picker

    range

    string (use value-as-number for number)

    Slider input

    - Read-only inputs, display-only values
  • .from-view - Capture input without updating view

  • .one-time - Static initial values

  • - Submit and process forms
  • Template Recipes - Real-world examples

  • Use computed properties - Let Aurelia handle form state reactively

  • Keep it simple - Don't overcomplicate with manual DOM manipulation

  • , not
    form[prop] = value
  • Missing labels - Always include labels for accessibility

  • text

    string

    General text input

    email

    string

    Email addresses

    Basic Inputs
    Collections
    Form Submission
    Collections
    Validation Plugin
    File Uploads
    Template Syntax Overview
    Attribute Binding
    Event Binding
    Validation Plugin

    password

    File Uploads
    Form Submission

    Basic Syntax

  • Supported Patterns

  • Restrictions and Limitations

  • Array Operations

  • Event Handling

  • Text Interpolation

  • Advanced Use Cases

  • Performance and Best Practices

  • Common Pitfalls

  • hashtag
    What Are Lambda Expressions?

    Lambda expressions are arrow functions that you can write directly in Aurelia template bindings. They provide a way to perform inline data transformations, filtering, and calculations without defining separate methods in your view model.

    Key Benefits:

    • Inline Logic: Write simple functions directly in templates

    • Reduced Boilerplate: Avoid creating view model methods for simple operations

    • Better Locality: Keep related logic close to where it's used

    • Reactive Updates: Automatically re-evaluate when dependencies change

    hashtag
    Basic Syntax

    Aurelia supports these arrow function patterns:

    Real Template Usage:

    hashtag
    Supported Patterns

    Lambda expressions work in multiple binding contexts:

    hashtag
    Restrictions and Limitations

    Aurelia's lambda expressions support only expression bodies. The following JavaScript arrow function features are not supported:

    Error Messages: When you attempt to use unsupported features, you'll receive these specific error codes:

    • Block bodies: AUR0178: "arrow function with function body is not supported"

    • Default parameters: AUR0174: "arrow function with default parameters is not supported"

    • Destructuring: AUR0175: "arrow function with destructuring parameters is not supported"

    hashtag
    Array Operations

    Lambda expressions work with all standard JavaScript array methods. Aurelia automatically observes array changes and property access within lambda expressions.

    hashtag
    Basic Array Operations

    Lambda expressions work with all standard JavaScript array methods:

    hashtag
    Chaining Operations

    Combine multiple array methods for complex transformations:

    hashtag
    Reactive Property Access

    Aurelia automatically tracks property access in lambda expressions:

    hashtag
    Event Handling

    Lambda expressions work with all Aurelia event bindings:

    Custom Attributes with Lambdas:

    hashtag
    Text Interpolation

    Use lambda expressions in text interpolation for inline calculations:

    hashtag
    Accessing Template Context

    Lambda expressions can access $this and $parent for scope navigation, maintaining proper binding context:

    Scope Access Patterns:

    • $this: References the current binding context (view model)

    • $parent: References the parent binding context

    • $parent.$parent: Chain to access higher-level scopes

    • Maintains reactivity: Changes to referenced properties trigger updates

    hashtag
    Advanced Use Cases

    hashtag
    Nested Array Processing

    Process complex nested data structures with proper scope handling:

    Nested Processing Benefits:

    • Maintains lexical scope: Outer variables accessible in inner functions

    • Reactive updates: Changes to nested properties trigger re-evaluation

    • Performance optimized: Aurelia observes the right level of nesting

    hashtag
    Dynamic Search and Filtering

    Create responsive search interfaces:

    hashtag
    Immediate Invoked Arrow Functions (IIFE)

    Execute functions immediately within templates using arrow function IIFE patterns:

    IIFE Use Cases:

    • Calculations: Perform complex math without cluttering view model

    • Data transformation: Transform values inline with specific logic

    • Scoped operations: Execute multi-step operations in template context

    hashtag
    Performance and Best Practices

    hashtag
    Automatic Property Observation

    Aurelia automatically observes properties accessed within lambda expressions:

    hashtag
    Array Mutation Handling

    Aurelia observes array mutations for most methods:

    Sorting Considerations:

    Why slice() before sort()?

    • sort() mutates the original array, triggering multiple change notifications

    • slice() creates a copy, preventing mutation conflicts with the repeater

    • Ensures stable rendering when the source array changes

    hashtag
    Performance Guidelines

    • Keep expressions simple: Complex logic should move to view model methods

    • Avoid deep nesting: Limit chained operations for readability

    • Use specific property access: Reference specific properties for optimal observation

    • Profile when needed: Monitor performance in large lists with complex transformations

    hashtag
    Common Pitfalls

    hashtag
    Mutation vs. Immutable Operations

    hashtag
    Expression Complexity

    hashtag
    Debugging Tips

    • Isolate expressions: Test lambda expressions in browser console first

    • Use intermediate variables: Break complex chains into steps in your view model

    • Check property names: Ensure referenced properties exist and are observable

    • Verify data structure: Confirm arrays and objects have expected shape

    • Parser state corruption: If experiencing strange errors, check for syntax issues in other expressions that might corrupt the parser state

    hashtag
    Framework Implementation Notes

    AST Structure:

    • Lambda expressions compile to ArrowFunction AST nodes

    • Support rest parameters via boolean flag

    • Body must be an expression (IsAssign type)

    • Parameters are stored as BindingIdentifier[]

    Error Codes:

    • AUR0173: Invalid arrow parameter list

    • AUR0174: Default parameters not supported

    • AUR0175: Destructuring parameters not supported

    • AUR0176: Rest parameter must be last

    • AUR0178: Function body (block statements) not supported

    hashtag
    Summary

    Lambda expressions in Aurelia templates provide a powerful way to write inline logic without cluttering your view models. They excel at array transformations, simple calculations, and event handling. Remember to:

    • Use only expression bodies (no curly braces)

    • Leverage automatic property observation for reactive updates

    • Keep expressions simple and readable

    • Move complex logic to view model methods when needed

    • Use slice() before mutating operations like sort() for safety

    With these guidelines, lambda expressions can significantly improve your template code's clarity and maintainability.

    What Are Lambda Expressions?
    Expand/collapse panels
  • Single or multiple panels open

  • Smooth animations

  • Keyboard navigation

  • Accessible with ARIA attributes

  • Customizable styling

  • hashtag
    Component Code

    hashtag
    accordion.ts

    hashtag
    accordion.html

    hashtag
    accordion-panel.ts

    hashtag
    accordion-panel.html

    hashtag
    accordion.css

    hashtag
    Usage Examples

    hashtag
    Basic Accordion (Single Panel Open)

    hashtag
    Multiple Panels Open

    hashtag
    Controlled Open Panels

    hashtag
    With Rich Content

    hashtag
    Testing

    hashtag
    Accessibility Features

    This accordion implements WCAG 2.1 guidelines:

    • ✅ ARIA Attributes: aria-expanded indicates panel state

    • ✅ Keyboard Support: Enter and Space keys toggle panels

    • ✅ Focus Management: Buttons are focusable with visible focus indicators

    • ✅ Semantic HTML: Uses <button> for interactive headers

    hashtag
    Enhancements

    hashtag
    1. Add Animation Callbacks

    hashtag
    2. Add Icons

    hashtag
    3. Add Lazy Loading

    hashtag
    Best Practices

    1. Animation Performance: Use max-height instead of height: auto for smooth transitions

    2. Content Height: Set reasonable max-height values or calculate dynamically

    3. Accessibility: Always include aria-expanded for screen readers

    4. Focus Visible: Ensure keyboard focus is clearly visible

    5. Mobile: Test touch interactions and ensure adequate tap targets

    hashtag
    Summary

    You've built a fully-functional accordion with:

    • ✅ Single and multiple panel modes

    • ✅ Smooth animations

    • ✅ Keyboard support

    • ✅ Accessible markup

    • ✅ Easy customization

    This accordion is ready for FAQs, settings panels, and any collapsible content!

    import { 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;
        }
      }
    }
    Optional CSS styling
    circle-info

    Component Naming

    Component names must include a hyphen (e.g., user-card, nav-menu) to comply with Web Components standards. Use a consistent prefix like app- or your organization's initials for better organization.

    hashtag
    Creating Your First Component

    The simplest way to create a component is with convention-based files:

    export class UserCard {
      name = 
    
    <div class="user-card">
      <
    

    Aurelia automatically pairs user-card.ts with user-card.html by convention, creating a <user-card> element you can use in templates.

    hashtag
    Component Configuration

    Use the @customElement decorator for explicit configuration:

    For simple naming, use the shorthand syntax:

    hashtag
    Configuration Options

    Key @customElement options:

    Template Configuration:

    hashtag
    Importing external HTML templates with bundlers

    When a component imports an .html file, the bundler must deliver that file as a plain string. Otherwise tools such as Vite, Webpack, and Parcel try to parse the file as an entry point and emit errors like [vite:build-html] Unable to parse HTML; parse5 error code unexpected-character-in-unquoted-attribute-value or "template" is not exported by src/components/product-name-search.html.

    Configure your bundler using the option that best matches your stack:

    • Vite / esbuild (default Aurelia starter), Parcel 2, Rollup + @rollup/plugin-string – append ?raw to the import so the bundler treats the file as text:

      import template from './product-name-search.html?raw';

      Add a matching declaration so TypeScript understands these imports (the query string can be reused for other text assets):

      declare module '*.html?raw' {
        const content: string;
        export default content;
      }
    • Webpack 5 – mark .html files as asset/source (or keep using raw-loader). After that you can import without a query parameter:

    • Other bundlers – use the equivalent “treat this file as a string” hook (e.g., SystemJS text plugin).

    Once the bundler understands .html files as text, both npm start and npm run build can reuse the same component source without inline templates. Keep the import pattern consistent across the project so contributors immediately know which loader configuration applies.

    Dependencies:

    hashtag
    Alternative Creation Methods

    Static Configuration:

    Programmatic (mainly for testing):

    hashtag
    HTML-Only Components

    Create simple components with just HTML:

    Usage:

    hashtag
    Viewless Components

    Components that handle DOM manipulation through third-party libraries:

    hashtag
    Using Components

    Global Registration (in main.ts):

    Local Import (in templates):

    hashtag
    Containerless Components

    Render component content without wrapper tags:

    Or configure inline:

    circle-exclamation

    Use Sparingly

    Containerless components lose their wrapper element, which can complicate styling, testing, and third-party library integration.

    hashtag
    Component Lifecycle

    Components follow a predictable lifecycle. Implement only the hooks you need:

    circle-info

    See Component Lifecycles for comprehensive lifecycle documentation.

    hashtag
    Bindable Properties

    Components accept data through bindable properties:

    See Bindable Properties for complete configuration options.

    hashtag
    Advanced Features

    hashtag
    Shadow DOM

    Enable Shadow DOM for complete style and DOM encapsulation:

    Shadow DOM is useful for:

    • Complete style isolation (styles won't leak in or out)

    • Creating reusable components with predictable styling

    • Using native <slot> elements for content projection

    • Building design systems and component libraries

    See the Shadow DOM guide for detailed configuration, styling patterns, and best practices.

    hashtag
    Template Processing

    Transform markup before compilation:

    hashtag
    Enhancing Existing DOM

    Apply Aurelia to existing elements:

    hashtag
    Reactive Properties

    Watch for property changes:

    hashtag
    Child Element Observation

    hashtag
    Component Configuration

    Attribute Capture:

    Aliases:

    hashtag
    Best Practices

    hashtag
    Component Design

    • Single Responsibility: Each component should have one clear purpose

    • Type Safety: Use interfaces for complex data structures

    • Composition: Favor composition over inheritance

    hashtag
    Performance

    • Use attached() for DOM-dependent initialization

    • Clean up subscriptions in detaching()

    • Prefer @watch over polling for reactive updates

    • Consider Shadow DOM for style isolation

    hashtag
    Testing

    • Mock dependencies properly

    • Test lifecycle hooks and bindable properties

    • Write tests for error scenarios

    See Testing Components for detailed guidance.


    Components form the foundation of Aurelia applications. Start with simple convention-based components and add complexity as needed. The framework's flexibility allows you to adopt patterns that fit your project's requirements while maintaining clean, maintainable code.

    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>'
    });
    status-badge.html
    <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 });
      }
    }

    Notification System

    A complete notification system with auto-dismiss, multiple types, animations, and queue management.

    hashtag
    Features Demonstrated

    • Dependency Injection - Singleton service pattern

    • Event Aggregator - Global notification triggering

    • Animations - CSS transitions for enter/leave

    • Timers - Auto-dismiss with setTimeout

    • Array manipulation - Add/remove notifications

    • Dynamic CSS classes - Type-based styling

    • Conditional rendering - Show/hide based on array length

    hashtag
    Code

    hashtag
    Service (notification-service.ts)

    hashtag
    Component (notification-container.ts)

    hashtag
    Template (notification-container.html)

    hashtag
    Styles (notification-container.css)

    hashtag
    Registration (main.ts)

    hashtag
    Usage in Root Component (my-app.html)

    hashtag
    Usage in Any Component

    hashtag
    How It Works

    hashtag
    Singleton Service Pattern

    The INotificationService is registered as a singleton, so the same instance is shared across the entire application. Any component can inject it and trigger notifications.

    hashtag
    Auto-Dismiss Timer

    When a notification is added with duration > 0, a timer is created that automatically dismisses it after the specified time. The timer is stored in a Map so it can be cleared if the user manually dismisses the notification.

    hashtag
    Reactive Array

    The notifications array is a reactive property. When notifications are added or removed, Aurelia's binding system automatically updates the DOM.

    hashtag
    Progress Bar Animation

    The progress bar uses a computed property (getProgressWidth) that calculates the percentage remaining based on elapsed time. This creates a smooth countdown animation.

    hashtag
    Variations

    hashtag
    Stacking vs Replacing

    Current implementation stacks notifications. For "replacing" behavior (only show one at a time):

    hashtag
    Position Options

    Make position configurable:

    hashtag
    Action Buttons

    Add action buttons to notifications:

    hashtag
    Pause on Hover

    Pause the auto-dismiss timer when hovering:

    hashtag
    Related

    • - Singleton services

    • - Alternative global communication

    • - if.bind documentation

    Complete Getting Started Guide

    Complete getting started guide for Aurelia 2 - from installation to building your first interactive application in 15 minutes.

    Build a real Aurelia application in 15 minutes. This hands-on guide shows you why developers choose Aurelia for its performance, simplicity, and standards-based approach. No prior Aurelia experience required.

    hashtag
    What You'll Discover

    Build a polished task management app while experiencing Aurelia's key advantages:

    • 🚀 Instant two-way data binding - no boilerplate code required

    • ⚡ Blazing fast rendering - direct DOM updates, no virtual DOM overhead

    • 🎯 Intuitive component model - clean, testable architecture

    • 🛠️ Modern TypeScript development - with built-in dependency injection

    The result? A production-quality app with clean, maintainable code in just 15 minutes.

    hashtag
    Prerequisites

    You'll need:

    • Node.js 18+ (recommended latest LTS) ()

    • A code editor ( recommended)

    • Basic knowledge of HTML, CSS, and JavaScript

    hashtag
    Quick Try (No Installation)

    Want to see Aurelia in action immediately? Copy this into an HTML file:

    Open it in your browser and start typing! This demonstrates Aurelia's automatic two-way data binding.

    hashtag
    Create Your First Project

    hashtag
    Step 1: Initialize Project

    Create a new project using the makes command:

    When prompted:

    • Project name: my-task-app

    • Choose TypeScript or JavaScript template (TypeScript recommended)

    • Install dependencies: Yes

    Your app opens at http://localhost:9000 showing "Hello World!"

    hashtag
    Step 2: Project Structure

    Your new project contains:

    Key files to understand:

    • main.ts: Starts your Aurelia application

    • my-app.ts: Your root component's logic (TypeScript)

    • my-app.html: Your root component's template (HTML)

    hashtag
    Understanding Aurelia Components

    Aurelia apps are built with components. Each component has two parts:

    hashtag
    View-Model (Logic)

    src/my-app.ts:

    hashtag
    View (Template)

    src/my-app.html:

    The ${} syntax binds data from your view-model to the template. When message changes, the <h1> automatically updates!

    hashtag
    Build Your Task App

    Let's transform the hello world app into a task manager. We'll build it step by step.

    hashtag
    Step 3: Update the Template

    Replace contents of src/my-app.html:

    hashtag
    Step 4: Update the Logic

    Replace contents of src/my-app.ts:

    hashtag
    Step 5: Add Styles

    Replace contents of src/my-app.css:

    hashtag
    Step 6: See It Work!

    Save your files and check your browser. You now have a fully functional task manager! Try:

    • Adding tasks by typing and clicking "Add Task" or pressing Enter

    • Completing tasks by checking the checkboxes

    • Removing tasks by clicking the × button

    hashtag
    Key Concepts You Just Learned

    hashtag
    1. Data Binding

    Aurelia automatically keeps your HTML in sync with your TypeScript properties.

    hashtag
    2. Event Handling

    Connect user interactions to your methods seamlessly.

    hashtag
    3. Conditional Rendering

    Show or hide elements based on conditions.

    hashtag
    4. List Rendering

    Display dynamic lists that update automatically.

    hashtag
    5. Computed Properties

    Derived values that update automatically when dependencies change.

    hashtag
    Next Steps

    Congratulations! You've built a real Aurelia application. Here's what to explore next:

    hashtag
    Immediate Next Steps

    • - Create reusable components

    • - Master Aurelia's templating

    • - Manage services and data

    hashtag
    Building Real Apps

    • - Add navigation between pages

    • - Handle complex user input

    • - Connect to APIs

    hashtag
    Development Workflow

    • - Optimize your development setup

    • - Test your applications

    • - Debug effectively

    hashtag
    Common Questions

    hashtag
    "Should I use TypeScript or JavaScript?"

    TypeScript is recommended for better development experience, error catching, and IntelliSense. But JavaScript works perfectly fine too.

    hashtag
    "How does this compare to React/Vue/Angular?"

    Aurelia focuses on standards-based development with minimal learning curve. If you know HTML, CSS, and JavaScript, you already know most of Aurelia.

    hashtag
    "Can I use this in production?"

    Absolutely! Aurelia 2 is production-ready and used by companies worldwide. The framework is stable, performant, and well-tested.

    hashtag
    "What if I get stuck?"

    • - Comprehensive guides and API docs

    • - Community Q&A

    • - Real-time chat with the community

    hashtag
    You're Ready!

    You now understand Aurelia's core concepts and have built a working application. The framework's strength lies in its simplicity - what you just learned covers 80% of what you'll use in real applications.

    Ready to build something amazing? Dive into the guides above or start building your next project with Aurelia!

    Attribute transferring

    Forward bindings from a custom element to its inner template using Aurelia's spread operators.

    Attribute transferring lets a custom element pass bindings it receives down to elements inside its own template. It keeps component APIs small while still exposing the flexibility callers need.

    As an application grows, the components inside it also grow. Something that starts simple like the following component

    export class FormInput {
      @bindable label
      @bindable value
    }

    with the template

    <label>${label}
      <input value.bind="value">
    </label>

    can quickly grow out of hand with a number of needs for configuration: aria, type, min, max, pattern, tooltip, validation etc...

    After a while, the FormInput component above will become more and more like a relayer that simply passes bindings through. The number of @bindable properties increases and maintenance becomes tedious:

    export class FormInput {
      @bindable label
      @bindable value
      @bindable type
      @bindable tooltip
      @bindable arias
      @bindable etc
    }

    And the usage of such element may look like this

    to be repeated like this inside:

    Coordinating all of those bindings isn't difficult, just repetitive. Attribute transferring, conceptually similar to JavaScript spread syntax, reduces the template to:

    This moves the bindings declared on <form-input> onto the <input> element inside the component.

    hashtag
    Aurelia Spread Operators Overview

    Aurelia provides several spread operators for different use cases:

    Operator
    Purpose
    Example

    Each operator serves a specific purpose in component composition and data flow.

    hashtag
    Usage

    To transfer attributes and bindings from a custom element, there are two steps:

    • Set capture to true on a custom element via @customElement decorator:

    This tells the template compiler to capture bindings and attributes (with some exceptions) for later reuse.

    • Spread the captured attributes onto an element:

    circle-exclamation

    Avoid chaining attribute transfer across many component layers. Deep “prop drilling” is difficult to follow and can become a maintenance burden. Keep transfers to at most one or two levels.

    hashtag
    How it works

    hashtag
    What attributes are captured

    Everything except template controller and custom element bindables are captured. For the following example:

    View model:

    Usage:

    What are captured:

    • value.bind="extraComment"

    • class="form-control"

    • style="background: var(--theme-purple)"

    hashtag
    How will attributes be applied in ...$attrs

    Attributes that are spread onto an element will be compiled as if it was declared on that element.

    This means .bind command will work as expected when it's transferred from some element onto some element that uses .two-way for .bind.

    It also means that spreading onto a custom element will also work: if a captured attribute is targeting a bindable property of the applied custom element. An example:

    if value is a bindable property of my-input, the end result will be a binding that connects the message property of the corresponding app.html view model with <my-input> view model value property. Binding mode is also preserved like normal attributes.

    hashtag
    Advanced Spread Patterns

    hashtag
    Mixed Binding Patterns

    You can combine multiple spread operators and explicit bindings on the same element:

    Binding Priority (last wins):

    1. ...$bindables / ...expression (first)

    2. ...$attrs (second)

    3. Explicit bindings (last, highest priority)

    Note: According to the existing documentation, ...$attrs will always result in bindings after ...$bindables/$bindables.spread/...expression, regardless of their order in the template.

    hashtag
    Complex Member Access

    Spread operators support complex expressions:

    hashtag
    Conditional Spreading

    You can conditionally spread attributes based on expressions:

    hashtag
    Automatic Expression Inference

    Aurelia can automatically infer property names in certain binding scenarios:

    hashtag
    Shorthand Binding Syntax

    hashtag
    Inference Rules

    • Property name must match the attribute name exactly

    • Only works with simple property access (no expressions)

    • Works with all binding commands (.bind, .two-way, .one-way, etc.)

    hashtag
    Performance Considerations

    hashtag
    Binding Creation Optimization

    Spread operators include several performance optimizations:

    hashtag
    One-time Change Detection

    Spread operations are optimized to prevent unnecessary binding updates:

    hashtag
    Memory Usage Guidelines

    • Spread operators create bindings for each property accessed

    • Large objects with many properties create many bindings

    • Consider using specific bindable properties for frequently changing data

    • Use spreading primarily for configuration and setup data

    hashtag
    Error Handling & Edge Cases

    hashtag
    Null and Undefined Handling

    Spread operators handle null and undefined values gracefully:

    hashtag
    Invalid Expressions

    hashtag
    Type Safety with TypeScript

    TypeScript provides compile-time validation for spread operations:

    hashtag
    Advanced Capture Patterns

    hashtag
    Capture Filtering

    Filter which attributes are captured using a function:

    hashtag
    Multi-level Capture Guidelines

    circle-exclamation

    Best Practice: Limit capture levels to 2-3 maximum to maintain code clarity and avoid prop-drilling anti-patterns.

    hashtag
    Template Controller Compatibility

    Spread operators work with template controllers:

    hashtag
    Integration Examples

    hashtag
    Component Composition

    Usage:

    hashtag
    Working with Third-party Components

    hashtag
    Dynamic Component Creation

    hashtag
    Common Patterns and Best Practices

    hashtag
    1. Configuration Objects

    hashtag
    2. Conditional Properties

    hashtag
    3. Proxy Objects for Transformation

    hashtag
    4. Default Values with Spreading

    This comprehensive documentation now covers all the advanced patterns and edge cases developers might encounter when working with Aurelia's spread operators.

    From Vue to Aurelia

    Vue developers: Love Vue's simplicity? Aurelia takes it further with better performance, stronger TypeScript support, and zero magic.

    Vue developer? You already appreciate simple, intuitive frameworks. Aurelia takes that philosophy even further with better performance, stronger TypeScript support, and standards-based architecture.

    hashtag
    Why Vue Developers Love Aurelia

    hashtag

    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    '
    John Doe
    '
    ;
    email = '[email protected]';
    }
    h3
    >
    ${name}
    </
    h3
    >
    <p>${email}</p>
    </div>
    List Rendering - repeat.for documentation
    Dependency Injection
    Event Aggregator
    Conditional Rendering
    Watching the counters update automatically
    Download herearrow-up-right
    VS Codearrow-up-right
    Components Guide
    Templates Deep Dive
    Dependency Injection
    Router
    Forms
    HTTP Client
    Build Tools
    Testing
    Debugging
    Documentation
    GitHub Discussionsarrow-up-right
    Discordarrow-up-right

    Shorthand for bindable spreading

    <my-component ...user>

    tooltip="Hello, ${tooltip}" What are not captured:

  • if.bind="needsComment" (if is a template controller)

  • label.bind="label" (label is a bindable property)

  • Case-sensitive: firstName.bind infers firstName, not firstname

    ...$attrs

    Spread captured attributes from parent element

    <input ...$attrs>

    ...$bindables

    Spread object properties to bindable properties

    <my-component ...$bindables="user">

    ...expression

    // 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 aurelia
    cd my-task-app
    npm run dev
    my-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 scripts
    export 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's Simplicity + Better Performance

    Result: Same clean code style, but with direct DOM updates and no proxy overhead.

    hashtag
    ✨ Better TypeScript Integration

    hashtag
    🚀 Standards-Based Architecture

    Aurelia's approach: Build on web standards instead of creating new syntax.

    hashtag
    Your Vue Knowledge Transfers Perfectly

    hashtag
    Template Syntax Comparison

    Vue
    Aurelia
    Benefit

    v-model="value"

    value.bind="value"

    Two-way binding works the same

    v-if="condition"

    if.bind="condition"

    Same conditional logic

    hashtag
    Component Structure

    hashtag
    Reactivity Comparison

    hashtag
    Component Communication

    hashtag
    Migration Path: Vue → Aurelia

    hashtag
    1. Quick Start (5 minutes)

    hashtag
    2. Convert Your First Vue Component (10 minutes)

    Let's convert a typical Vue component:

    hashtag
    3. Experience the Differences

    • No ref() wrappers - plain JavaScript properties

    • No .value everywhere - direct property access

    • Better TypeScript - no generic complications

    • Automatic CSS loading - matching CSS files load automatically

    hashtag
    What You'll Gain Moving from Vue

    hashtag
    📈 Performance Improvements

    • Direct DOM updates instead of virtual DOM reconciliation

    • Smaller runtime - no proxy reactivity overhead

    • Better tree shaking - more efficient bundling

    • Faster startup - less framework initialization code

    hashtag
    🧹 Cleaner Development Experience

    • No composition API complexity - just class properties and methods

    • Better TypeScript support - built for TypeScript from day one

    • Simpler testing - no special test utilities needed

    • Standards-based - closer to web platform APIs

    hashtag
    🚀 Enhanced Capabilities

    • Built-in dependency injection - no need for provide/inject complexity

    • Powerful templating - lambda expressions and better binding

    • Shadow DOM support - true component encapsulation

    • Better routing - type-safe, more powerful navigation

    hashtag
    Vue vs Aurelia: Feature Comparison

    Feature
    Vue 3
    Aurelia 2
    Winner

    Learning Curve

    Easy

    Easy

    Tie

    TypeScript Support

    Good

    hashtag
    Ready to Experience the Upgrade?

    Next Steps:

    1. Complete Getting Started Guide - Build a real app in 15 minutes

    2. Component Guide - Master component patterns

    3. Templates Deep Dive - Advanced templating

    4. Dependency Injection - Powerful DI system

    Questions? Join our Discord communityarrow-up-right where developers discuss framework experiences and best practices.

    Ready to take the next step? Start building with Aurelia and experience web development the way it should be.

    <!-- 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 dev

    processContent

    Learn how to manipulate the DOM from the usage-side of a custom element using the processContent hook.

    There are scenarios where we would like to transform the template provided by the usage-side. The 'processContent' hook lets us define a pre-compilation hook to make that transformation.

    The signature of the hook function is as follows.

    // pseudo-code; `typeof TCustomElement` doesn't work in Generics form.
    <TCustomElement>(this: typeof TCustomElement, node: INode, platform: IPlatform) => boolean | void;

    There are two important things to note here.

    First is the node argument. It is the DOM tree on the usage-side for the custom element. For example, if there is a custom element named my-element, on which a 'processContent' hook is defined, and it is used somewhere as shown in the following markup, then when the hook is invoked, the node argument will provide the DOM tree that represents the following markup.

    <my-element>
     <foo></foo>
     <bar></bar>
    </my-element>

    Then inside the hook this DOM tree can be transformed/mutated into a different DOM tree. The mutation can be addition/removal of attributes or element nodes.

    Second is the return type boolean | void. Returning from this function is optional. Only an explicit false return value results in skipping the compilation (and thereby enhancing) of the child nodes in the DOM tree. The implication of skipping the compilation of the child nodes is that Aurelia will not touch those DOM fragments and will be kept as it is. In other words, if the mutated node contains custom elements, custom attributes, or template controllers, those will not be hydrated.

    The platform argument is just the helper to have platform-agnostic operations as it abstracts the platform. Lastly the this argument signifies that the hook function always gets bound to the custom element class function for which the hook is defined.

    The most straight forward way to define the hook is to use the processContent property while defining the custom-element.

    Apart from this, there is also the @processContent decorator which can used class-level or method-level.

    That's the API. Now let us say consider an example. Let us say that we want to create a custom elements that behaves as a tabs control. That is this custom element shows different sets of information grouped under a set of headers, and when the header is clicked the associated content is shown. To this end, we can conceptualize the markup for this custom element as follows.

    The markup has 2 slots for the header and content projection. While using the tabs custom element we want to have the following markup.

    circle-info

    If you are unfamiliar with the au-slot then visit the . 'processContent' can be very potent with au-slot.

    Now note that there is no custom element named tab. The idea is to keep the usage-markup as much dev-friendly as possible, so that it is easy to maintain, and the semantics are quite clear. Also it is easy to refactor as now we know which parts belong together. To support this usage-syntax we will use the 'processContent' hook to rearrange the DOM tree, so that the nodes are correctly projected at the end. A prototype implementation is shown below.

    circle-info

    Note the use of $host scope reference that is used when generating markup that will be projected into the slot. Host is required to access the activeTabId property and the showTab function of the Tabs custom element that is hosting the projected markup. More details available at .

    Example transformation function for default [au-slot]

    If you have used , you might have noticed that in order to provide a projection the usage of [au-slot] attribute is mandatory, even if the projections are targeted to the default au-slot. With the help of the 'processContent' hook we can workaround this minor inconvenience. The following is a sample transformation function that loops over the direct children under node and demotes the nodes without any [au-slot] attribute to a synthetic template[au-slot] node.

    spinner
    spinner
    spinner
    spinner
    spinner
    spinner

    Dropdown Menu

    Build a fully-featured dropdown menu component with keyboard navigation and accessibility

    Learn to build a production-ready dropdown menu with keyboard navigation, accessibility, and click-outside detection. This component is perfect for navigation menus, context menus, and action lists.

    hashtag
    What We're Building

    A dropdown menu that supports:

    v-for="item in items"

    repeat.for="item of items"

    Same iteration, better performance

    @click="handler"

    click.trigger="handler"

    Same event handling

    :class="{ active: isActive }"

    active.class="isActive"

    Cleaner conditional classes

    Excellent

    Aurelia

    Performance

    Good

    Better

    Aurelia

    Bundle Size

    Small

    Smaller

    Aurelia

    Template Syntax

    Custom

    Standards-based

    Aurelia

    State Management

    Pinia/Vuex

    Built-in DI

    Aurelia

    Component Communication

    Props/Emits

    Bindable/Callable

    Tie

    Ecosystem Size

    Large

    Focused

    Vue

    spinner
    spinner
    documentation
    Slotted contentarrow-up-right
    au-slot
    Click to toggle open/close
  • Keyboard navigation (Arrow keys, Enter, Escape)

  • Click outside to close

  • Accessible with ARIA attributes

  • Customizable trigger and content

  • Positioning options

  • hashtag
    Component Code

    hashtag
    dropdown-menu.ts

    hashtag
    dropdown-menu.html

    hashtag
    dropdown-menu.css

    hashtag
    Usage Examples

    hashtag
    Basic Dropdown

    hashtag
    With Custom Trigger

    hashtag
    Programmatic Control

    hashtag
    Disabled State

    hashtag
    Testing

    Test your dropdown component:

    hashtag
    Accessibility Features

    This dropdown implements WCAG 2.1 guidelines:

    • ✅ Keyboard Navigation: Full keyboard support with arrow keys

    • ✅ ARIA Attributes: Proper role, aria-haspopup, aria-expanded, aria-hidden

    • ✅ Focus Management: Focuses first item when opened, returns focus to trigger when closed

    • ✅ Escape to Close: Standard Escape key behavior

    • ✅ Screen Reader Support: Announces menu state and items

    hashtag
    Enhancements

    hashtag
    1. Add Icons to Menu Items

    hashtag
    2. Add Submenus

    Nest another dropdown-menu inside:

    hashtag
    3. Add Search/Filter

    hashtag
    4. Add Positioning Intelligence

    Use a library like Floating UI to automatically position the menu to avoid viewport overflow:

    hashtag
    Best Practices

    1. Always Clean Up: Remove event listeners in detaching() to prevent memory leaks

    2. Focus Management: Return focus to trigger when closing for better UX

    3. Debounce: For search/filter, debounce input to avoid excessive filtering

    4. Accessibility: Test with keyboard only and screen readers

    5. Portal Rendering: For complex layouts, render menu in a portal to avoid z-index issues

    hashtag
    Summary

    You've built a fully-featured dropdown menu with:

    • ✅ Click and keyboard interactions

    • ✅ Accessibility built-in

    • ✅ Click-outside detection

    • ✅ Customizable trigger and content

    • ✅ Comprehensive tests

    This dropdown is production-ready and can be extended with search, submenus, and intelligent positioning!

    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`
      });
    }

    From Angular to Aurelia

    Angular developers: Keep the best parts (DI, TypeScript, CLI) while eliminating the complexity and improving performance.

    Angular developer? You'll feel right at home with Aurelia. Keep everything you love—dependency injection, TypeScript, powerful CLI—while eliminating boilerplate, improving performance, and simplifying your development experience.

    hashtag
    Why Angular Developers Choose Aurelia

    hashtag
    🎯 All the Power, None of the Complexity

    Result: 70% less code with the same functionality and better performance.

    hashtag
    🚀 Dependency Injection Without the Complexity

    hashtag
    ✨ Better TypeScript Integration

    hashtag
    Your Angular Knowledge Transfers

    hashtag
    Template Syntax Translation

    Angular
    Aurelia
    Benefit

    hashtag
    Component Architecture

    hashtag
    Services and DI Comparison

    hashtag
    Migration Benefits for Angular Developers

    hashtag
    📈 Performance Gains

    • No Zone.js overhead - direct DOM updates instead of change detection

    • Smaller bundle sizes - less framework code, better tree shaking

    • Faster startup - no complex bootstrap process

    hashtag
    🧹 Development Experience Improvements

    • Less boilerplate - no modules, less ceremony

    • Simpler testing - no TestBed setup complexity

    • Better debugging - inspect actual DOM, not framework abstractions

    hashtag
    🚀 Modern Development Features

    • Built-in hot reload - better development experience

    • Automatic CSS loading - no need to import stylesheets

    • Shadow DOM support - true component encapsulation

    hashtag
    Quick Migration Path

    hashtag
    1. Set Up Your Aurelia Environment (5 minutes)

    hashtag
    2. Convert Your First Angular Component (15 minutes)

    Take any Angular component and follow this pattern:

    hashtag
    3. Experience the Improvements

    • No change detection cycles - updates happen directly

    • No modules to configure - components work immediately

    • Better TypeScript support - everything is typed by default

    hashtag
    Angular vs Aurelia: Feature Comparison

    Feature
    Angular
    Aurelia
    Winner

    hashtag
    What Angular Concepts Work in Aurelia

    ✅ Dependency Injection - Even more powerful and simpler ✅ TypeScript - First-class support, better integration ✅ Component Architecture - Same concepts, cleaner implementation ✅ Services - Same patterns, less boilerplate ✅ Routing - More powerful, type-safe navigation ✅ Testing - Simpler setup, same testing patterns ✅ CLI Tools - Full-featured CLI for scaffolding and building

    hashtag
    Ready for a Better Angular Experience?

    Next Steps:

    1. - Build a real app in 15 minutes

    2. - Master Aurelia's DI system

    3. - Type-safe navigation

    Questions? Join our where developers discuss enterprise framework experiences and architectural decisions.

    Ready to experience Angular without the complexity? today.

    Advanced custom attributes

    Advanced patterns for building custom attributes in Aurelia 2, including template controllers, complex bindings, and performance optimization.

    This guide covers advanced patterns for building custom attributes in Aurelia 2, focusing on template controllers, complex binding scenarios, and performance optimization techniques.

    hashtag
    Template Controllers

    Template controllers are custom attributes that control the rendering of their associated template. They're the mechanism behind built-in attributes like if, repeat, with, and switch.

    hashtag
    Basic Template Controller Structure

    All template controllers follow this pattern:

    Usage:

    hashtag
    Real-World Example: Visibility Controller

    A practical template controller that shows/hides content based on user permissions:

    Usage:

    hashtag
    Advanced Template Controller: Loading States

    A template controller that manages loading states with caching:

    Usage:

    hashtag
    Complex Two-Way Binding Attributes

    hashtag
    Bi-directional Data Synchronization

    Create attributes that can both read and write data:

    Usage:

    hashtag
    Multi-Value Binding

    Handle multiple bindable properties with complex interactions:

    Usage:

    hashtag
    Performance Optimization Patterns

    hashtag
    Lazy Initialization

    Defer expensive operations until needed:

    hashtag
    Batch Updates

    Minimize DOM operations by batching updates:

    hashtag
    Error Handling in Custom Attributes

    hashtag
    Graceful Degradation

    Handle errors gracefully without breaking the application:

    hashtag
    Validation and Sanitization

    Validate inputs before applying them:

    hashtag
    Testing Custom Attributes

    hashtag
    Unit Testing Template Controllers

    hashtag
    Best Practices

    hashtag
    1. Resource Management

    Always clean up resources in detaching():

    hashtag
    2. Performance Considerations

    • Use requestAnimationFrame for DOM updates

    • Batch operations when possible

    • Avoid frequent DOM queries

    hashtag
    3. Error Handling

    • Validate inputs before applying changes

    • Provide fallback behaviors

    • Log errors for debugging

    hashtag
    4. Type Safety

    • Use TypeScript interfaces for bindable properties

    • Implement proper type guards for runtime validation

    These patterns provide a solid foundation for building robust, performant custom attributes that integrate well with Aurelia's architecture while handling edge cases gracefully.

    Search Autocomplete

    A complete autocomplete/typeahead search component with keyboard navigation, highlighting, and debouncing.

    hashtag
    Features Demonstrated

    • Two-way data binding - Search input

    • Debouncing - Optimize API calls

    • Computed properties - Filtered results

    • Keyboard navigation - Arrow keys, Enter, Escape

    • Focus management - Keep track of selected item

    • Click outside - Close dropdown

    • Custom attributes - Auto-focus

    • Template references - Access DOM elements

    • Conditional rendering - Loading states, empty states

    hashtag
    Code

    hashtag
    Component (search-autocomplete.ts)

    hashtag
    Template (search-autocomplete.html)

    hashtag
    Styles (search-autocomplete.css)

    hashtag
    Usage Example

    hashtag
    How It Works

    hashtag
    Debouncing

    The queryChanged callback uses setTimeout to debounce API calls. When the user types, previous timers are cleared, so only the final query triggers a search after the specified delay.

    hashtag
    Keyboard Navigation

    The component handles arrow keys to navigate results, Enter to select, and Escape to close. The selected index tracks which item is highlighted, and scrollIntoView ensures it's visible.

    hashtag
    Click Outside

    A global click listener detects clicks outside the component and closes the dropdown. The listener is added in attached() and cleaned up in detaching().

    hashtag
    Highlighting Matches

    The highlightMatch method uses regex to wrap matching text in <mark> tags. The result is bound with innerhtml.bind to render the HTML.

    circle-exclamation

    Security Note

    Be careful with innerhtml.bind. In this case it's safe because we're only highlighting text we control. For user-generated content, use the sanitize value converter.

    hashtag
    Accessibility

    • role="combobox" on input

    • role="listbox" on dropdown

    • role="option" on results

    hashtag
    Variations

    hashtag
    Recent Searches

    Store and show recent searches when input is focused but empty:

    hashtag
    Grouped Results

    Group results by category:

    hashtag
    Infinite Scroll

    Load more results as user scrolls:

    hashtag
    Related

    • - Keyboard events

    • - if.bind documentation

    • - ref attribute

    Event binding

    Event binding in Aurelia 2 offers a streamlined approach to managing DOM events directly within your templates. By declaratively attaching event listeners in your view templates, you can effortlessly respond to user interactions like clicks, keystrokes, form submissions, and more. This guide explores the intricacies of event binding in Aurelia 2, providing detailed explanations and practical examples to deepen your understanding and effective utilization of this feature.

    hashtag
    Understanding Event Binding

    Aurelia 2 simplifies the connection between DOM events and your view model methods. It employs a clear and concise syntax, enabling you to specify the event type and the corresponding method to be invoked in your view model when that event occurs.

    click.trigger="handler()"

    Same event handling

    *ngIf="condition"

    if.bind="condition"

    Cleaner conditional syntax

    *ngFor="let item of items"

    repeat.for="item of items"

    Same iteration, better performance

    [class.active]="isActive"

    active.class="isActive"

    More intuitive class binding

    Better runtime performance - efficient batched updates
    Cleaner templates - HTML that looks like HTML
    Standards-based - closer to web platform APIs
    Cleaner templates - HTML without framework-specific syntax

    Aurelia

    Performance

    Good with OnPush

    Better by default

    Aurelia

    Learning Curve

    Steep

    Gentle

    Aurelia

    Bundle Size

    Large

    Smaller

    Aurelia

    CLI Tools

    Excellent

    Excellent

    Tie

    Enterprise Features

    Comprehensive

    Comprehensive

    Tie

    Ecosystem

    Huge

    Focused

    Angular

    Standards Compliance

    Good

    Excellent

    Aurelia

    - Test your applications

    [property]="value"

    property.bind="value"

    Same one-way binding

    [(ngModel)]="value"

    value.bind="value"

    Simpler two-way binding

    TypeScript Support

    Excellent

    Excellent

    Tie

    Dependency Injection

    Powerful but complex

    Complete Getting Started Guide
    Dependency Injection Guide
    Router Guide
    Testing Guide
    Discord communityarrow-up-right
    Start building with Aurelia

    (click)="handler()"

    Powerful and simple

    aria-expanded indicates dropdown state

  • aria-activedescendant points to selected item

  • Keyboard navigation follows ARIA practices

  • Bindable Properties - Component inputs

  • Value Converters - sanitize for HTML binding

  • Event Binding
    Conditional Rendering
    Template References
    // 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 dev
    import { 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();
      }
    }
    hashtag
    Event Binding Syntax

    The general syntax for event binding in Aurelia 2 follows this pattern:

    • <element>: The HTML element to which you are attaching the event listener.

    • event: The name of the DOM event you wish to listen for (e.g., click, input, mouseover).

    • .command: The binding command that instructs Aurelia how to handle the event. Common commands are .trigger and .capture.

    • methodName: The name of the method in your view model that will be executed when the event is dispatched.

    • argument1, argument2, ...: Optional arguments that you can pass to the methodName.

    hashtag
    Event Binding Commands: .trigger and .capture

    Aurelia 2 primarily offers two commands for event binding, each controlling the event listening phase:

    1. .trigger: This command attaches an event listener that reacts to events during the bubbling phase. This is the most frequently used and generally recommended command for event binding as it aligns with typical event handling patterns in web applications. Events are first captured by the deepest element and then propagate upwards through the DOM tree.

    2. .capture: This command listens for events during the capturing phase. Capturing is the less common phase where events propagate downwards from the window to the target element. .capture is typically used in specific scenarios, such as when you need to intercept an event before it reaches child elements, potentially preventing default behaviors or further propagation.

    circle-info

    The .delegate command from Aurelia 1 has been removed in Aurelia 2. If you need to migrate from Aurelia 1 code that uses .delegate, you can use the @aurelia/compat-v1 package, or simply replace .delegate with .trigger in most cases, as .trigger in Aurelia 2 efficiently handles event bubbling for dynamic content.

    hashtag
    Example: Click Event Binding using .trigger

    To bind a click event on a button to a method named handleClick in your view model, you would use:

    When a user clicks the "Click Me" button, Aurelia will execute the handleClick method defined in your associated view model.

    hashtag
    Shorthand syntax for events (@event)

    To make it easier for teams migrating from Vue or other frameworks, Aurelia also understands the @event="handler" shorthand. The compiler converts it to the equivalent event.trigger binding, including modifiers after a colon.

    Use whichever style you prefer—the generated instructions are the same. If you need capturing semantics, use the explicit event.capture syntax because the shorthand only targets the bubbling (.trigger) command.

    hashtag
    Passing Event Data to Handlers

    Often, you need access to the event object or want to pass additional data to your event handler method. Aurelia provides a straightforward way to do this.

    To pass the DOM event object itself to your handler, use the $event special variable:

    In your view model, the handleClick method would accept the event object as a parameter:

    You can also pass custom arguments along with the event:

    hashtag
    Common DOM Events

    Aurelia 2 supports binding to all standard DOM events. Here are some frequently used events in web development:

    hashtag
    click

    The click event is triggered when a pointing device button (typically a mouse button) is both pressed and released while the pointer is inside the element. It is commonly used for buttons, links, and interactive elements.

    hashtag
    input

    The input event fires when the value of an <input>, <textarea>, or <select> element has been changed. It's useful for real-time validation or dynamic updates based on user input.

    hashtag
    change

    The change event is fired when the value of an element has been changed and the element loses focus. This is often used for <input>, <select>, and <textarea> elements when you want to react after the user has finished making changes.

    hashtag
    mouseover and mouseout

    The mouseover event occurs when the mouse pointer is moved onto an element, and mouseout occurs when it is moved off of an element. These are useful for hover effects and interactive UI elements.

    hashtag
    keydown, keyup, and keypress

    These keyboard events are triggered when a key is pressed down, released, or pressed and released, respectively. keydown and keyup are generally preferred for capturing special keys like arrows, Ctrl, Shift, etc., while keypress is more suited for character input.

    hashtag
    Controlling Event Propagation

    In DOM event handling, events can "bubble" up the DOM tree (from the target element up to the document) or "capture" down (from the document to the target element). Sometimes you need to control this propagation. Within your event handler methods, you can use methods of the event object to manage propagation:

    • event.stopPropagation(): Prevents the event from further bubbling up the DOM tree to parent elements.

    • event.preventDefault(): Prevents the default action associated with the event (if it's cancelable), without stopping event propagation. For example, preventDefault on a click event of a link (<a>) would stop the browser from navigating to the link's href.

    hashtag
    Advanced Event Binding Techniques

    Aurelia 2 provides capabilities beyond basic event binding, allowing for performance optimization and handling specific scenarios.

    hashtag
    Throttling and Debouncing Event Handlers

    For events that fire rapidly and repeatedly, such as mousemove, scroll, or input, calling an event handler function on every event can be performance-intensive. Aurelia's binding behaviors offer throttle and debounce to limit the rate at which your handler is invoked.

    Throttling: Ensures a function is called at most once in a specified time interval.

    In this example, trackMouse will be executed at most every 50 milliseconds, even if mousemove events are firing more frequently.

    Debouncing: Delays the execution of a function until after a certain amount of time has passed since the last time the event was triggered. Useful for autocomplete or search features to avoid making API calls on every keystroke.

    Here, searchQuery will be called 300ms after the user stops typing, reducing the number of search requests.

    hashtag
    Performance Considerations

    When using throttling and debouncing, consider these performance best practices:

    • Choose appropriate delays: Too short delays may not provide performance benefits, while too long delays can make the UI feel unresponsive.

    • Monitor handler complexity: Ensure that even throttled/debounced handlers are optimized for performance.

    • Use signals for immediate updates: When you need to force immediate execution of a throttled/debounced handler (e.g., on form submission), use signals:

    hashtag
    Custom Events

    Aurelia 2 fully supports custom events, which are essential when working with custom elements or integrating third-party libraries that dispatch their own events.

    In this scenario, data-loaded is a custom event emitted by <my-custom-element>. handleDataLoaded in the parent view model will be invoked when this custom event is dispatched.

    hashtag
    Event Binding Examples and Use Cases

    To solidify your understanding, let's explore practical examples showcasing different event binding scenarios in Aurelia 2.

    hashtag
    Self-Delegating Events with .self

    The self binding behavior ensures that an event handler is only triggered if the event originated directly from the element to which the listener is attached, and not from any of its child elements (due to event bubbling).

    In this setup, divClicked() will only be executed if the click originates directly on the <div> element. Clicks on the <button> (a child element) will trigger buttonClicked() but will not bubble up to trigger divClicked() due to the & self behavior.

    hashtag
    Checkbox change Event and Two-Way Binding

    Combine event binding with two-way binding for interactive form elements like checkboxes.

    Here, checked.bind="isAgreed" keeps the isAgreed property in sync with the checkbox state (two-way binding). change.trigger="agreementChanged()" additionally allows you to execute custom logic when the checkbox state changes.

    hashtag
    Handling Keyboard Events for Specific Keys

    React to specific key presses within input fields.

    This example shows how to check event.key to handle specific keys like "Enter" and "Escape".

    hashtag
    Event Delegation for Dynamic Lists

    Event delegation is a powerful technique for efficiently handling events on dynamically generated lists. Attach a single event listener to the parent <ul> or <div> instead of individual listeners to each list item using .trigger.

    The listItemClicked handler attached to the <ul> will be triggered for clicks on any <li> within it due to event bubbling. We check event.target to ensure the click originated from an <li> and extract the data-item-id. This approach provides efficient event handling for dynamic lists without requiring individual listeners on each item.

    hashtag
    Custom Event Communication Between Components

    Parent components can listen for and react to custom events dispatched by child custom elements.

    Custom Element (Child):

    Parent Component (Parent):

    When the button in <my-button> is clicked, it dispatches a custom event button-clicked. The parent component listens for this event using button-clicked.trigger and executes handleButtonClick, receiving event details in $event.detail.

    hashtag
    Autocomplete with Debounced Input

    Implement autocomplete functionality with debouncing to reduce API calls during typing.

    The autocomplete method will be called 500ms after the last input event. This delay allows users to finish typing before triggering the (simulated) autocomplete API call, improving performance.

    hashtag
    Event Modifiers: Enhancing Event Handling

    Event modifiers provide a declarative way to apply conditions or actions to event bindings directly in your templates. Event modifiers are appended to the event name after a colon:

    Aurelia provides built-in modifiers for common event handling scenarios, and you can extend them with custom mappings.

    Modifier
    Works with
    Description

    prevent

    Any event

    Calls event.preventDefault() before running your handler.

    stop

    Any event

    Calls event.stopPropagation() before running your handler.

    Modifiers are additive: @click:ctrl+enter.prevent checks modifier keys first and only then calls your handler (after canceling the DOM default). If a modifier check fails (for example, the required key is not pressed) the handler simply does not run.

    hashtag
    Mouse and Keyboard Event Modifiers

    Aurelia has built-in support for modifiers related to mouse buttons and keyboard keys.

    Example: ctrl Key Modifier

    Execute onCtrlClick() only when the button is clicked and the Ctrl key is pressed.

    Example: ctrl+enter Key Combination

    Execute send() only when the Enter key is pressed while the Ctrl key is also held down. Modifiers can be combined using +.

    hashtag
    prevent and stop Modifiers

    Declaratively call event.preventDefault() and event.stopPropagation() using modifiers.

    Example: prevent and stop Modifiers

    Call validate() when the button is clicked, and also prevent the default button behavior and stop event propagation.

    hashtag
    Mouse Button Modifiers: left, middle, right

    Handle clicks based on specific mouse buttons.

    Example: middle Mouse Button Modifier

    Execute newTab() only when the button is clicked with the middle mouse button.

    hashtag
    Keyboard Key Mappings and Custom Modifiers

    You can use character codes as modifiers for keyboard events. For example, 75 is the char code for uppercase 'K'.

    Example: Ctrl + K using Char Code Modifier

    Execute openSearchDialog() when Ctrl + K is pressed in the textarea.

    While using char codes works, it can be less readable. You can create custom key mappings to use more descriptive modifier names. For example, map upper_k to the key code for 'K'.

    Custom Key Mapping Setup (in your main application file, e.g., main.ts):

    Now you can use :upper_k as a modifier:

    This makes your template more readable as :ctrl+upper_k is more self-explanatory than :ctrl+75.

    circle-info

    Aurelia provides default key mappings for lowercase letters 'a' through 'z' (both as key codes and letter names). For uppercase letters, only key code mappings are provided by default (e.g., :65 for 'A'). You can extend these mappings as shown above to create more semantic modifier names.

    hashtag
    Extending modifier handling

    The runtime registers EventModifier, IModifiedEventHandlerCreator, and a set of default creators (mouse, keyboard, generic) inside EventModifierRegistration. If you need custom semantics—gestures, wheel direction checks, or application-specific shortcuts—add your own creator and register it with the container:

    After registration you can bind to @wheel:vertical.invert="onScroll($event)". Returning false from the handler vetoes the event (your view-model method will not be called), while returning true allows the binding to proceed.

    hashtag
    Common Pitfalls and Troubleshooting

    hashtag
    Event Handler Issues

    1. Event not firing: Verify that the event name is correct and the element supports that event type.

    2. Handler not found: Ensure the method exists in your view model and is properly spelled.

    3. Context issues: Remember that event handlers execute in the context of the view model, so this refers to the view model instance.

    hashtag
    Performance Issues

    1. Frequent event handlers: Use throttling or debouncing for events that fire rapidly (e.g., mousemove, scroll, input).

    2. Complex handlers: Keep event handlers lightweight. Move heavy processing to separate methods called asynchronously.

    3. Memory leaks: Aurelia automatically manages event listener cleanup, but be cautious with manual event listeners in your handlers.

    hashtag
    Binding Behavior Conflicts

    1. Multiple rate limiters: You cannot apply both throttle and debounce to the same binding (Error AUR9996).

    2. Duplicate behaviors: Avoid applying the same binding behavior multiple times (Error AUR0102).

    3. Behavior order: When chaining behaviors, order matters: event.trigger="handler() & behavior1 & behavior2".

    hashtag
    Event Modifier Issues

    1. Incorrect syntax: Modifiers must be placed after the command: click.trigger:ctrl not click:ctrl.trigger.

    2. Unsupported modifiers: Verify that the modifier is supported for the event type.

    3. Custom modifiers: Ensure custom key mappings are registered before use.

    hashtag
    Debugging Tips

    1. Console logging: Add console.log statements to verify event firing:

      handleClick(event: MouseEvent) {
        console.log('Click handler called', event);
        // Your logic here
      }
    2. Browser dev tools: Use the browser's event listener inspection to verify bindings are attached.

    3. Event object inspection: Log the entire event object to understand available properties:

      handleEvent(event: Event) {
        console.log('Event details:', {
          type: event.type,
          target: event.target,
          currentTarget: event.currentTarget
        });
      }

    hashtag
    Conclusion

    Event binding in Aurelia 2 is a powerful and intuitive mechanism for creating interactive web applications. By mastering the syntax, commands, event modifiers, and advanced techniques like throttling and custom events, you can effectively handle user interactions and build dynamic, responsive user interfaces. Leverage the .trigger command for typical scenarios and .capture when you need to intercept events during the capturing phase. With these tools and patterns, you can craft a seamless and engaging user experience in your Aurelia 2 applications.

    <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)
    );

    Styling components

    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

    Dynamic styling is a fundamental aspect of modern web applications, and Aurelia 2 provides powerful, flexible mechanisms for binding CSS classes and styles to your elements. Whether you need to toggle an active state, implement a theming system, or create responsive layouts, Aurelia's binding system makes these tasks straightforward and maintainable.

    This comprehensive guide covers everything from basic class toggling to advanced styling techniques, giving you the knowledge and tools to implement any styling requirement in your Aurelia 2 applications.

    hashtag
    Basic Class Binding

    The most common use case for dynamic styling is conditionally applying CSS classes based on component state.

    hashtag
    Single Class Binding: The .class Syntax

    The .class binding is the foundation of dynamic styling in Aurelia. The syntax is straightforward:

    How it works: The syntax is className.class="booleanExpression". When the expression is truthy, the class is added. When it's falsy, the class is removed.

    circle-info

    Note: You can use any valid CSS class name, including ones with special characters like my-awesome-class.class="isAwesome" or Unicode characters like ✓.class="isComplete".

    circle-exclamation

    TailwindCSS note: Tailwind’s content scanner won’t pick up class names that only appear in attribute names. For Tailwind classes that include special characters (for example width-[360px]), prefer the object form with class.bind so the class token appears in an attribute value:

    hashtag
    Multiple Classes: Comma-Separated Syntax

    When you need to toggle multiple related classes together, you can use comma-separated class names:

    Important: No spaces around the commas! The parser expects class1,class2,class3, not class1, class2, class3.

    hashtag
    Style Binding

    Aurelia provides multiple approaches for binding CSS styles, from individual properties to complex style objects.

    hashtag
    Single Style Properties

    To bind individual CSS properties dynamically, use the .style syntax:

    hashtag
    Alternative Style Syntax

    Aurelia supports two equivalent syntaxes for style binding:

    Use whichever feels more natural to you. Some developers prefer the first syntax because it reads like "set the background-color style to myColor", while others prefer the second because it's more similar to traditional CSS.

    hashtag
    CSS Custom Properties

    Aurelia fully supports CSS custom properties (CSS variables), enabling powerful theming capabilities:

    hashtag
    Vendor Prefixes

    Aurelia supports vendor-prefixed CSS properties for cross-browser compatibility:

    hashtag
    The !important Declaration

    Aurelia automatically handles the !important CSS declaration when included in style values:

    hashtag
    Advanced Class Binding Techniques

    Advanced class binding techniques provide greater flexibility for complex styling scenarios.

    hashtag
    String-Based Class Binding

    For scenarios requiring more flexibility than boolean toggling, you can bind class strings directly:

    When to use what:

    • .class syntax: When you need boolean toggling of specific classes

    • class.bind: When you need to build class strings dynamically

    • Template interpolation: When you want to mix static and dynamic classes

    hashtag
    Advanced Style Binding

    Advanced style binding techniques enable sophisticated styling patterns and better code organization.

    hashtag
    Object-Based Style Binding

    For complex styling scenarios, bind an entire style object:

    hashtag
    String Interpolation

    Combine static and dynamic styles using template interpolation:

    hashtag
    Computed Style Properties

    Create dynamic styles based on component state:

    hashtag
    Component Styling Strategies

    Beyond template bindings, Aurelia provides several approaches for styling components themselves.

    hashtag
    Convention-Based Styling

    Aurelia automatically imports stylesheets that match your component names:

    This means you can focus on writing CSS without worrying about imports:

    hashtag
    Shadow DOM

    For complete style isolation, use Shadow DOM:

    Shadow DOM Configuration Options:

    hashtag
    Shadow DOM Special Selectors

    Shadow DOM provides special CSS selectors for enhanced styling control:

    hashtag
    Global Shared Styles in Shadow DOM

    To share styles across Shadow DOM components, configure shared styles in your application:

    hashtag
    CSS Modules

    CSS Modules provide scoped styling by transforming class names to unique identifiers at build time. Aurelia provides the cssModules() helper to integrate CSS Modules with your components:

    The cssModules() helper transforms class names in your template at compile time. In the example above, class="title" becomes class="title_abc123".

    Key features:

    • Works with static classes, class.bind, and interpolation (class="some ${myClass}")

    • Supports multi-class binding syntax (class1,class2.class="condition")

    • Each component must register its own cssModules()

    For more details on using CSS Modules with Shadow DOM, see the .

    hashtag
    Real-World Examples and Patterns

    The following examples demonstrate practical applications of class and style binding techniques in common scenarios.

    hashtag
    Responsive Design with Dynamic Classes

    hashtag
    Theme System with CSS Variables

    hashtag
    Loading States with Animations

    hashtag
    Complex Form Validation Styling

    hashtag
    Performance Tips and Best Practices

    hashtag
    Do's and Don'ts

    ✅ DO:

    • Use .class for simple boolean toggling

    • Use CSS custom properties for theming

    • Prefer computed getters for complex style calculations

    ❌ DON'T:

    • Inline complex style calculations in templates

    • Use string concatenation for class names when .class will do

    • Forget about CSS specificity when using !important

    hashtag
    Performance Optimization

    hashtag
    Troubleshooting Common Issues

    hashtag
    "My styles aren't updating!"

    Problem: Styles don't change when data changes. Solution: Make sure you're using proper binding syntax and that your properties are observable.

    hashtag
    "My CSS classes have weird names!"

    Problem: Using CSS Modules and seeing transformed class names. Solution: This is expected behavior! CSS Modules transform class names to ensure uniqueness.

    hashtag
    "Shadow DOM is blocking my global styles!"

    Problem: Global CSS frameworks aren't working inside Shadow DOM components. Why: Shadow DOM isolates styles; open/closed mode does not change CSS encapsulation. Solutions:

    • Use Light DOM for components that should inherit global framework styles

    • Register framework CSS as shared styles with StyleConfiguration.shadowDOM({ sharedStyles: [...] })

    • Use CSS variables or ::part to expose safe customization points

    Learn more:

    hashtag
    Migration and Compatibility

    hashtag
    Coming from Aurelia 1?

    The syntax is mostly the same, with some improvements:

    hashtag
    Browser Support

    All binding features work in modern browsers. For older browsers:

    • Shadow DOM requires a polyfill for older browsers

    • CSS Modules work everywhere (they're processed at build time)

    hashtag
    Summary

    This guide has covered the complete range of class and style binding capabilities in Aurelia 2. Key takeaways include:

    1. Basic class binding - Use .class syntax for simple boolean toggling

    2. Multiple class binding - Leverage comma-separated syntax for related classes

    3. Style property binding - Apply individual CSS properties with .style syntax

    These techniques provide the foundation for building maintainable, dynamic user interfaces that respond effectively to application state changes.


    Additional Resources: For more information on binding syntax, see the . To understand when styles are applied, refer to the documentation.

    Collections (Checkboxes, Radios, Select)

    Learn how to work with checkboxes, radio buttons, select elements, and advanced collection patterns in Aurelia forms.

    hashtag
    Overview

    Aurelia provides sophisticated support for collection-based form controls, going beyond simple arrays to support Sets, Maps, and custom collection types with optimal performance.

    hashtag
    Checkboxes

    hashtag
    Boolean Checkboxes

    The simplest checkbox pattern binds to boolean properties:

    Key Points:

    • Use checked.bind for boolean checkboxes

    • Works with any boolean property

    • Great for independent on/off toggles

    hashtag
    Array-Based Multi-Select

    For multi-select scenarios, bind arrays to checkbox groups using model.bind:

    How It Works:

    1. model.bind tells Aurelia what value to add to the array

    2. checked.bind points to the array that holds selected values

    3. Aurelia automatically adds/removes values when checkboxes are toggled

    Use Cases:

    • Multi-select forms (select multiple skills, interests, tags)

    • Batch operations (select multiple items for deletion)

    • Filter selections (select multiple categories to filter by)

    hashtag
    Set-Based Collections

    For high-performance scenarios with frequent additions/removals, use Set collections:

    Why Use Sets:

    • O(1) lookup performance with .has()

    • Efficient for large collections

    • Natural for unique value storage

    hashtag
    Map-Based Collections

    For complex key-value selections, Maps provide the most flexibility:

    When to Use Maps:

    • Nested selection scenarios (resource → actions)

    • Complex key-value relationships

    • Grouped permissions or settings

    • Multi-dimensional selections

    hashtag
    Radio Buttons

    Radio buttons are for single-selection from multiple options.

    hashtag
    Basic Radio Buttons

    hashtag
    Radio Buttons with Objects

    Key Points:

    • Use same name attribute for all radios in a group

    • model.bind defines the value when selected

    • checked.bind holds the currently selected value

    hashtag
    Select Elements

    hashtag
    Basic Select

    hashtag
    Select with Objects

    hashtag
    Select with Optgroups

    hashtag
    Multi-Select

    hashtag
    Performance Considerations

    Choose the right collection type for your use case:

    Collection Type
    Best For
    Performance

    Performance Tips:

    • Use Set for large collections with frequent changes

    • Implement efficient matcher functions for object comparison

    • Avoid creating new objects in templates—use computed properties

    • Consider virtualization for very large checkbox/radio lists

    hashtag
    Matchers Explained

    By default, Aurelia compares values with strict equality (===). That works for primitives and for objects that are the exact same instance. It does not work when your selected value and your option values are different instances that represent the same logical entity (for example, data reloaded from an API). A matcher lets you define what "equal" means so selections stay in sync.

    Matchers tell Aurelia how to compare values:

    When to use matchers:

    • Binding objects to checkboxes/radios

    • Working with Sets containing objects

    • Need custom equality logic

    • Comparing by properties other than reference

    hashtag
    Common Patterns

    hashtag
    Select All / Deselect All

    hashtag
    Conditional Options

    hashtag
    Related

    • - Basic form inputs

    • - Validate form inputs

    • - Complete examples

    Product Catalog

    A complete product catalog featuring real-time search, category filtering, sorting, and responsive design. This recipe demonstrates how to build a performant, user-friendly product browsing experience.

    hashtag
    Features Demonstrated

    • Two-way data binding - Search input with instant updates

    Modifying template parsing with AttributePattern

    Aurelia's attribute pattern system allows you to create custom template syntax extensions that can emulate other framework syntaxes like Angular or Vue, or define entirely new patterns for your specific needs. This powerful extensibility feature integrates directly with Aurelia's template compiler and binding engine.

    hashtag
    Architecture Overview

    The attribute pattern system consists of several core components:

    ctrl, alt, shift, meta

    Keyboard/Mouse events

    Ensures the corresponding meta key is pressed. Multiple keys can be chained (:ctrl+enter).

    Named keys (enter, escape, tab, a, ArrowUp, etc.)

    Keyboard events

    Only invokes your handler when the pressed key matches.

    left, middle, right

    Mouse events

    Filters mouse buttons.

    - mappings do not inherit to child components
    Use Shadow DOM for true component isolation
  • Cache complex style objects when possible

  • Mix too many styling approaches in one component

    Advanced techniques - Implement complex styling with objects, interpolation, and CSS variables

  • Component styling - Choose appropriate encapsulation strategies for your use case

  • Shadow DOM documentation
    Shadow DOM guide
    template syntax guidearrow-up-right
    component lifecycles
    Better performance for frequent add/remove operations

    Use matcher.bind for complex object comparison

    Key-value pairs, nested selections

    Excellent (O(1) lookups)

    - Using repeat.for

    Array

    General purpose, small-medium collections

    Good

    Set

    Frequent additions/removals, uniqueness

    Excellent (O(1) lookups)

    Form Basics
    Validationarrow-up-right
    Form Examplesarrow-up-right
    List Rendering

    Map

    <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>

    Computed properties - Filtered product list based on search and filters

  • repeat.for with keys - Efficient list rendering

  • Event handling - Sort buttons, filter checkboxes

  • Conditional rendering - Empty states, loading states

  • Value converters - Currency formatting

  • CSS class binding - Active filters, selected sort order

  • Debouncing - Optimize search performance

  • hashtag
    Code

    hashtag
    View Model (product-catalog.ts)

    hashtag
    Template (product-catalog.html)

    hashtag
    Styles (product-catalog.css)

    hashtag
    How It Works

    hashtag
    1. Search with Debouncing

    The search input uses debouncing to avoid excessive filtering operations:

    This waits 300ms after the user stops typing before updating searchQuery, which triggers the filteredProducts computed property.

    hashtag
    2. Reactive Filtering

    The filteredProducts getter automatically recalculates when any filter changes:

    hashtag
    3. Multiple Checkbox Selection

    Category filters use array binding:

    Aurelia automatically adds/removes items from the selectedCategories array.

    hashtag
    4. Efficient List Rendering

    Using key: id tells Aurelia to track products by ID, enabling efficient DOM updates when sorting or filtering:

    hashtag
    5. Dynamic CSS Classes

    The active sort button and out-of-stock cards use class binding:

    hashtag
    Variations

    hashtag
    Add Price Range Filter

    hashtag
    Add to Cart Functionality

    hashtag
    Persist Filters in URL

    Use the router to save filter state:

    hashtag
    Related

    • Shopping Cart Recipe

    • Data Table Recipe

    • List Rendering Guide

    • Conditional Rendering

    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 }
        });
      }
    }
    AttributePatternDefinition: Defines pattern structure with pattern and symbols
  • AttrSyntax: The parsed result containing binding information

  • SyntaxInterpreter: A finite state machine that efficiently parses attribute names

  • AttributeParser: Manages pattern registration and result caching

  • Pattern Priority System: Resolves conflicts when multiple patterns match

  • hashtag
    When to reach for attribute patterns

    Create an attribute pattern when the attribute name itself needs to convey extra meaning. Typical use cases include:

    • Porting syntaxes from other frameworks ([(value)], @click, :value, #ref).

    • Building DSLs where symbols separate intent (for example, data-track.click.once).

    • Collapsing multiple instructions into one attribute, such as emit:save or listen:customer.updated.

    If you simply need value.bind to default to two-way binding, prefer the attribute mapper. If you want to change how an attribute behaves after it has been parsed, reach for a binding command instead. Attribute patterns run before the mapper and binding commands, so they are ideal for inventing new syntaxes.

    hashtag
    Basic Pattern Definition

    hashtag
    AttributePatternDefinition Interface

    hashtag
    The PART Keyword

    PART in patterns represents dynamic segments that can match any characters except those defined in symbols. Think of PART as a flexible placeholder equivalent to the regex ([^symbols]+).

    hashtag
    Symbols Behavior

    The symbols property defines characters that:

    • Act as separators between pattern segments

    • Are excluded from PART matching

    • Can be used for readability and structure

    Example:

    • foo@bar → parts: ['foo', 'bar'] (with symbols)

    • Without symbols → parts: ['foo@', 'bar'] (without symbols)

    hashtag
    Pattern Class Structure

    hashtag
    Basic Pattern Class

    Note: AttrSyntax must be imported from @aurelia/template-compiler, not from the main aurelia package, as it's not currently re-exported there.

    hashtag
    Pattern Method Signature

    Each pattern method must:

    1. Have the exact same name as the pattern string

    2. Accept three required parameters:

      • rawName: string - Original attribute name (e.g., "[(value)]")

      • rawValue: string - Attribute value (e.g., "message")

      • parts: readonly string[] - Extracted PART values (e.g., ["value"])

    3. Return an AttrSyntax instance

    hashtag
    AttrSyntax Constructor

    The AttrSyntax class has the following constructor signature:

    hashtag
    AttrSyntax Parameters Explained

    Parameter
    Description
    Example

    rawName

    Original attribute name from template

    "[(value)]"

    rawValue

    Original attribute value

    "message"

    hashtag
    Common Binding Commands

    • 'bind' - One-way to view binding

    • 'to-view' - Explicit one-way to view

    • 'from-view' - One-way from view

    • 'two-way' - Two-way data binding

    • 'trigger' - Event binding

    • 'capture' - Event capture

    • 'ref' - Element/component reference

    • null - Custom or no specific command

    hashtag
    Pattern Registration

    hashtag
    Global Registration

    Register patterns globally at application startup:

    hashtag
    Local Registration

    Register patterns for specific components:

    hashtag
    Inline Pattern Definition

    For simple patterns, you can define them inline:

    hashtag
    Multiple Patterns per Class

    A single class can handle multiple related patterns:

    hashtag
    Pattern Priority System

    When multiple patterns could match the same attribute name, Aurelia uses a priority system:

    1. Static segments (exact text matches) have highest priority

    2. Dynamic segments (PART) have medium priority

    3. Symbol segments have lower priority

    Example Priority Resolution:

    hashtag
    Advanced Pattern Examples

    hashtag
    Event Modifiers

    hashtag
    Static Patterns (No PART)

    hashtag
    Complex Multi-PART Patterns

    hashtag
    Built-in Pattern Examples

    Aurelia includes several built-in patterns you can reference:

    hashtag
    Dot-Separated Patterns

    hashtag
    Shorthand Binding Patterns

    hashtag
    Framework Syntax Examples

    hashtag
    Angular-Style Patterns

    hashtag
    Vue-Style Patterns

    hashtag
    Performance Considerations

    hashtag
    Caching System

    The attribute parser maintains an internal cache of parsed interpretations. Once an attribute name is parsed, the result is cached for subsequent uses, improving template compilation performance.

    hashtag
    Pattern Optimization

    • Order Matters: More specific patterns should be defined first when possible

    • Symbol Selection: Choose symbols that don't conflict with common attribute patterns

    • Minimal Patterns: Avoid overly complex patterns that could match unintended attributes

    hashtag
    Registration Timing

    Patterns must be registered before template compilation begins. Late registration after the application starts may not take effect for already-compiled templates.

    hashtag
    Debugging and Error Handling

    hashtag
    Common Pattern Errors

    1. Missing Method: Pattern method name doesn't match pattern string exactly

    2. Wrong Signature: Method signature doesn't match required parameters

    3. Symbol Conflicts: Pattern symbols conflict with other registered patterns

    4. Registration Timing: Patterns registered after compilation begins

    hashtag
    Debugging Tips

    hashtag
    Pattern Testing

    Test your patterns with various attribute combinations:

    hashtag
    Integration with Template Compiler

    Attribute patterns integrate seamlessly with Aurelia's template compilation process:

    1. Template Analysis: The compiler scans for all attributes

    2. Pattern Matching: Each attribute name is tested against registered patterns

    3. Syntax Creation: Matching patterns create AttrSyntax objects

    4. Binding Generation: The compiler generates appropriate bindings based on the syntax

    5. Runtime Execution: Bindings execute during component lifecycle

    hashtag
    Working alongside binding commands and the attribute mapper

    • Attribute patterns decide the final target and command for an attribute. They are the only hook that can rewrite foo.bar.baz into whatever structure you need.

    • Binding commands use that parsed information to produce instructions. If your pattern returns command: 'permission', the binding command named permission will receive the attribute.

    • Attribute mapper only runs when command === 'bind'. If your pattern emits 'bind', the mapper can still remap value.bind to value.two-way or translate attribute names into DOM properties.

    Design patterns so they hand off clear targets and commands to the downstream pipeline. When in doubt, log the resulting AttrSyntax objects while authoring your pattern to confirm the values that later hooks will see.

    hashtag
    Best Practices

    hashtag
    Pattern Design

    1. Intuitive Syntax: Create patterns that feel natural to developers

    2. Consistent Naming: Follow consistent conventions across related patterns

    3. Clear Symbols: Use symbols that clearly separate pattern parts

    4. Avoid Conflicts: Test patterns against existing Aurelia syntax

    hashtag
    Registration Strategy

    1. Global vs Local: Use global registration for widely-used patterns, local for component-specific ones

    2. Bundle Size: Consider the impact of registering many patterns globally

    3. Tree Shaking: Local registration helps with tree shaking unused patterns

    hashtag
    Error Recovery

    1. Graceful Fallback: Design patterns to fail gracefully when they don't match

    2. Clear Errors: Provide meaningful error messages in pattern methods

    3. Validation: Validate pattern inputs and provide helpful feedback

    hashtag
    Complete Examples

    hashtag
    Custom Framework Integration

    hashtag
    Advanced Component System

    The attribute pattern system provides unlimited flexibility for creating custom template syntaxes that fit your team's needs or emulate familiar patterns from other frameworks, all while maintaining full integration with Aurelia's binding and compilation systems.

    hashtag
    Quick Reference Cheatsheet

    Here's a corrected cheatsheet with working examples:

    hashtag
    Next steps

    • Pair attribute patterns with the attribute mapper when you need to translate new syntaxes into existing DOM APIs.

    • Continue with Extending templating syntax to see how patterns, mappers, and observers work together end-to-end.

    • Explore custom binding commands whenever your pattern should hand off to bespoke runtime behavior instead of the default bind/two-way commands.

    export 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();

    Intermediate Tutorial

    Take your Aurelia skills to the next level by building a feature-rich todo application. This tutorial covers component composition, filtering, local storage, and real-world patterns.

    hashtag
    What You'll Learn

    • Creating multiple components and composing them

    • Component communication with bindable properties

    • Advanced list rendering and filtering

    • Form handling with validation

    • Local storage persistence

    • Computed properties and reactive updates

    • Template patterns for real apps

    hashtag
    Prerequisites

    • Completed the

    • Basic understanding of

    • Familiarity with TypeScript

    hashtag
    The App We're Building

    A todo application with:

    • ✅ Add, complete, and delete tasks

    • 🏷️ Categorize tasks (Work, Personal, Shopping)

    • 🔍 Filter by category and completion status

    • 💾 Auto-save to local storage

    hashtag
    Step 1: Project Setup

    hashtag
    Step 2: Data Models

    Create src/models.ts:

    hashtag
    Step 3: Storage Service

    Create src/storage-service.ts:

    The service is automatically registered as a singleton via DI.createInterface.

    hashtag
    Step 4: Main App Component

    Update src/my-app.ts:

    hashtag
    Step 5: Create Todo Form Component

    Create src/todo-form.ts:

    Create src/todo-form.html:

    hashtag
    Step 6: Create Todo Item Component

    Create src/todo-item.ts:

    Create src/todo-item.html:

    hashtag
    Step 7: Main App Template

    Update src/my-app.html:

    hashtag
    Step 8: Styling

    Update src/my-app.css:

    hashtag
    What You've Learned

    • Component Composition - Created reusable TodoForm and TodoItem components

    • Component Communication - Used @bindable and callback functions for parent-child communication

    • Dependency Injection - Created and injected StorageService

    hashtag
    Next Steps

    Enhance your app with:

    • Drag-and-drop reordering

    • Edit mode for todos

    • Due dates and reminders

    hashtag
    Related Documentation

    Modal Dialog

    Build a flexible modal dialog component with backdrop, animations, and focus management

    Learn to build a production-ready modal dialog with proper focus management, backdrop click handling, animations, and accessibility. Perfect for confirmations, forms, and detailed content displays.

    hashtag
    What We're Building

    A modal dialog that supports:

    • Open/close with smooth animations

    • Backdrop click to close (optional)

    • Escape key to close

    • Focus trap (keyboard focus stays within modal)

    • Return focus to trigger when closed

    • Accessible with ARIA attributes

    • Portal rendering (renders outside parent context)

    • Scrollable content

    hashtag
    Component Code

    hashtag
    modal-dialog.ts

    hashtag
    modal-dialog.html

    hashtag
    modal-dialog.css

    hashtag
    Usage Examples

    hashtag
    Basic Modal

    hashtag
    Confirmation Dialog

    hashtag
    Form Modal

    hashtag
    Full-Screen Modal

    hashtag
    Testing

    hashtag
    Accessibility Features

    This modal follows WCAG 2.1 guidelines:

    • ✅ Focus Trap: Tab key cycles through focusable elements within modal

    • ✅ Focus Management: Focuses first element when opened, returns focus when closed

    • ✅ Keyboard Support: Escape key closes modal

    hashtag
    Enhancements

    hashtag
    1. Add Transition Animations

    Use Aurelia's animation system for smoother transitions:

    hashtag
    2. Add Confirmation Before Close

    hashtag
    3. Add Modal Service

    Create a global modal service for programmatic modals:

    hashtag
    Best Practices

    1. Focus Management: Always return focus to the trigger element

    2. Body Scroll: Lock body scroll to prevent confusion

    3. Escape Key: Always allow Escape to close (unless critical action)

    hashtag
    Summary

    You've built a fully-featured modal dialog with:

    • ✅ Smooth animations

    • ✅ Focus trap and management

    • ✅ Keyboard support

    • ✅ Accessible markup

    This modal is production-ready and handles all common use cases!

    Attribute binding

    Attribute binding in Aurelia is a powerful feature that allows you to dynamically bind data from your view model to any native HTML attribute within your templates. This enables real-time updates to element attributes such as classes, styles, src, alt, and other standard HTML attributes, enhancing the interactivity and responsiveness of your applications.

    hashtag
    Basic Binding Syntax

    The fundamental syntax for binding to attributes in Aurelia is simple and intuitive:

    <div attribute-name.bind="value"></div>
    • attribute-name.bind="value": The binding declaration.

      • attribute-name: The target HTML attribute you want to bind to.

      • .bind: The default binding command (uses Aurelia's default binding mode for that target).

    You can bind to virtually any attribute listed in the .

    hashtag
    Example: Binding the title Attribute

    Result: The div will have a title attribute with the value "This is a tooltip". Hovering over the div will display the tooltip.

    circle-info

    When using an empty expression in a binding, such as attribute-name.bind or attribute-name.bind="", Aurelia automatically infers the expression based on the camelCase version of the target attribute. For example, attribute-name.bind="" is equivalent to attribute-name.bind="attributeName". This behavior applies to other binding commands as well:

    hashtag
    Binding Techniques and Syntax

    Aurelia provides multiple methods for attribute binding, each tailored for specific use cases and offering different levels of data flow control.

    hashtag
    1. Interpolation Binding

    Interpolation allows embedding dynamic values directly within strings. This is useful for concatenating strings with dynamic data.

    Example: Binding the id Attribute Using Interpolation

    Result: The h1 element will have an id attribute set to "main-heading".

    hashtag
    2. Keyword Binding

    Aurelia supports several binding keywords that define the direction and frequency of data flow between the view model and the view:

    • .one-time: Updates the view from the view model only once. Subsequent changes in the view model do not affect the view.

    • .to-view: Continuously updates the view from the view model.

    • .from-view

    hashtag
    Examples of Keyword Binding

    Result: The input fields and links will reflect the bound properties with varying degrees of reactivity based on the binding keyword used.

    hashtag
    3. Vue-style shorthand for .bind

    If you are used to Vue or other template syntaxes, Aurelia ships with an attribute pattern that treats a leading colon as an alias for .bind. This allows you to write more compact markup without giving up any functionality.

    The shorthand always creates a property binding (the same as attribute.bind). If you need a different binding mode, fall back to the explicit syntax (value.two-way, value.one-time, etc.). Event shorthands that start with @ are handled separately in the .

    hashtag
    3. Binding to Images

    Binding image attributes such as src and alt ensures that images update dynamically based on the view model data.

    Example: Dynamic Image Binding

    Result: The img element will display the image from the specified src and use the provided alt text.

    hashtag
    4. Disabling Elements

    Dynamically enabling or disabling form elements enhances user interaction and form validation.

    Example: Binding the disabled Attribute

    Result: The Submit button starts as disabled, and the input field is enabled. Calling toggleButton() or toggleInput() will toggle their disabled states.

    hashtag
    5. Binding innerHTML and textContent

    Choose between innerHTML for rendering HTML content and textContent for rendering plain text to control how content is displayed within elements.

    Example: Rendering HTML vs. Text

    Result:

    • The first div will render the bold text as HTML.

    • The second div will display the HTML tags as plain text.

    hashtag
    Advanced Binding Techniques

    Explore more sophisticated binding scenarios to handle complex data interactions and ensure seamless attribute management.

    hashtag
    0. Treating existing markup as a custom element with as-element

    When outside systems dictate the tag name you must render (for example, a CMS that only allows <div> and <section>), you can still hydrate one of your custom elements by adding as-element="component-name" to any real DOM node. The compiler will instantiate component-name for that element while leaving the original tag in place.

    • Use as-element when you want the behavior of a custom element but must keep the original tag name for semantic or styling reasons.

    • Unlike <template as-custom-element="...">, this does not create a local element definition; it simply aliases an existing element instance.

    • The attribute can appear anywhere inside your markup except on the root <template>

    This makes as-element a handy compatibility feature when integrating Aurelia components into environments with strict HTML requirements.

    hashtag
    1. How Attribute Binding Works

    Aurelia employs a mapping function to translate view model properties to corresponding HTML attributes. This typically involves converting kebab-case attribute names to camelCase property names. However, not all properties directly map to attributes, especially custom or non-standard attributes.

    Example: Automatic Mapping

    Result: The input element's value attribute is bound to the userName property. Changes in userName update the input value and vice versa.

    hashtag
    Property vs. Attribute Targeting

    By default, Aurelia bindings target DOM properties rather than HTML attributes. This distinction is important because:

    • Properties are JavaScript object properties on DOM elements (e.g., element.value)

    • Attributes are the HTML markup attributes (e.g., <input value="...">)

    For most standard HTML attributes, this works seamlessly because browsers synchronize property and attribute values. However, for custom attributes or when you specifically need attribute targeting, use the .attr binding command or the attr binding behavior.

    hashtag
    2. Using the .attr Binding Command

    When automatic mapping fails or when dealing with non-standard attributes, use the .attr binding command to ensure proper attribute binding.

    Example: Binding a Custom Attribute

    Result: The input element will have a my-custom-attr attribute set to "Custom Attribute Value".

    hashtag
    3. The attr Binding Behavior

    The attr binding behavior is a powerful feature that forces any property binding to target the HTML attribute instead of the DOM property. This is especially useful for:

    • Custom attributes that don't have corresponding DOM properties

    • Data attributes (data-*)

    • ARIA attributes

    Example: Using the attr Binding Behavior

    Result: All bindings will target their respective HTML attributes directly, ensuring proper DOM attribute manipulation.

    circle-exclamation

    The attr binding behavior can only be used with property bindings (.bind, .one-way, .two-way, .to-view, .from-view). It cannot be used with event bindings (.trigger, .capture) or reference bindings (.ref

    hashtag
    4. Attribute Mapping and Custom Elements

    Aurelia includes a built-in attribute mapper that handles common HTML attribute-to-property mappings automatically. For example:

    • maxlength → maxLength

    • readonly → readOnly

    • tabindex

    You can extend this mapping for custom elements or third-party components:

    hashtag
    5. Special Attribute Handling

    Aurelia provides specialized handling for certain attributes:

    hashtag
    Class Attributes

    hashtag
    Style Attributes

    hashtag
    Practical Use Cases

    To better illustrate attribute bindings, here are several practical scenarios showcasing different binding techniques.

    hashtag
    1. Dynamic Class Binding

    Example: Toggling CSS Classes

    Result: The div will have the class active when isActive is true and inactive when false. Calling toggleStatus() toggles the class.

    hashtag
    2. Styling Elements Dynamically

    Example: Binding Inline Styles

    Result: The div's background color reflects the current value of bgColor. Invoking changeColor('coral') will update the background to coral.

    hashtag
    3. Conditional Attribute Rendering

    Example: Conditionally Setting the required Attribute

    Result: The input field will be required based on the isEmailRequired property. Toggling this property will add or remove the required attribute.

    hashtag
    Notes on Syntax

    While attribute binding in Aurelia is versatile and robust, there are certain syntactical nuances and limitations to be aware of to prevent unexpected behavior.

    1. Expression Syntax Restrictions

      • No Chaining with ; or ,: Expressions within ${} cannot be chained using semicolons ; or commas ,. Each interpolation expression should represent a single, complete expression.

    circle-info

    For complex transformations or formatting, consider using Aurelia's value converters instead of embedding extensive logic within interpolation expressions. This practice enhances code maintainability and separation of concerns.

    hashtag
    Example: Using Value Converters for Formatting

    Binding with a Value Converter

    Result: Displays the totalPrice formatted as currency, e.g., "$199.99".

    hashtag
    Troubleshooting and Common Issues

    hashtag
    1. Binding Behavior Errors

    Error: AUR9994 - Invalid Binding Type for 'attr' Binding Behavior

    This error occurs when the attr binding behavior is used with non-property bindings:

    hashtag
    2. Null and Undefined Values

    When bound properties are null or undefined, attributes are removed from the DOM:

    hashtag
    3. Custom Attribute Conflicts

    When using .attr binding command, Aurelia bypasses custom attribute detection:

    hashtag
    4. Boolean Attribute Handling

    HTML boolean attributes (like disabled, checked, readonly) have special handling:

    hashtag
    5. SVG Attributes

    SVG attributes may require the attr binding behavior for proper functionality:

    hashtag
    Performance Considerations

    hashtag
    1. Binding Mode Selection

    Choose the appropriate binding mode for optimal performance:

    • Use .one-time for static values that never change

    • Use .to-view for display-only data

    • Use .two-way only when bidirectional synchronization is needed

    hashtag
    2. Expression Complexity

    Keep binding expressions simple for better performance:

    hashtag
    3. Computed Properties

    Use computed properties with proper dependencies for efficient updates:

    You can also use the shorthand syntax for simple dependencies:

    hashtag
    Best Practices

    hashtag
    1. Consistent Naming

    Use consistent naming conventions for attributes and properties:

    hashtag
    2. Type Safety

    Leverage TypeScript for better development experience:

    hashtag
    3. Error Boundaries

    Handle potential errors in binding expressions:

    hashtag
    4. Accessibility

    Ensure proper accessibility attributes:

    hashtag
    Summary

    Attribute binding in Aurelia offers a flexible and powerful means to synchronize data between your view model and the DOM. By understanding and utilizing the various binding commands and techniques, you can create dynamic, responsive, and maintainable user interfaces. Always consider the specific needs of your project when choosing between different binding strategies, and leverage Aurelia's features to their fullest to enhance your application's interactivity and user experience.

    Key takeaways:

    • Use the appropriate binding mode for your use case

    • Understand the difference between property and attribute targeting

    • Leverage the attr binding behavior for custom attributes

    target

    The target property, element, or identifier

    "value"

    command

    Binding command type

    "two-way", "bind", "trigger", "ref"

    parts

    Additional parts for complex patterns

    For event modifiers, extended syntax

    Value Converters

    📊 Task statistics

    Computed Properties - Implemented filtered lists and statistics

  • List Rendering - Used repeat.for with keys for efficient updates

  • Conditional Rendering - Showed/hid elements based on state

  • Form Handling - Built forms with validation and submission

  • Local Storage - Persisted data across sessions

  • Template Patterns - Applied real-world templating techniques

  • Search
    functionality
  • Dark mode toggle

  • Export/import todos

  • Form Handling

  • List Rendering

  • Hello World Tutorial
    Templates
    Templates Overview
    Component Basics
    Extended Tutorial
    Dependency Injection
    ✅ ARIA Attributes: role="dialog", aria-modal="true" for screen readers
  • ✅ Body Scroll Lock: Prevents scrolling background content

  • Backdrop Click: Make it configurable, disable for forms with unsaved changes
  • Portal Rendering: For complex apps, render modals in a portal at document root

  • Stacking: Support multiple modals with z-index management

  • ✅ Multiple sizes

  • ✅ Customizable behavior

  • npx makes aurelia
    # Name: todo-app
    # Select TypeScript
    cd todo-app
    npm run dev
    export 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);
      }
    }

    value: The expression or property from the view model to bind.

    .one-time
  • .to-view

  • .from-view

  • .two-way

  • .attr

  • : Updates the view model based on changes in the view.
  • .two-way: Establishes a two-way data flow, keeping both the view and view model in sync.

  • .bind: Automatically determines the appropriate binding mode. Defaults to .two-way for form elements (e.g., input, textarea) and .to-view for most other elements.

  • surrogate (putting it there triggers AUR0702).
    SVG attributes
  • Cases where you need to ensure attribute-specific behavior

  • ).
    →
    tabIndex
  • contenteditable → contentEditable

  • Restricted Primitives and Operators: Certain JavaScript primitives and operators cannot be used within interpolation expressions. These include:

    • Boolean

    • String

    • instanceof

    • typeof

    • Bitwise operators (except for the pipe | used with value converters)

  • Usage of Pipe |: The pipe character | is reserved exclusively for Aurelia's value converters within bindings and cannot be used as a bitwise operator.

  • Attribute Targeting Syntax

    The presence of both .bind and .attr syntaxes can be confusing. Here's why both exist:

    • Property vs. Attribute Binding: .bind targets the DOM property, which is suitable for standard attributes that have corresponding DOM properties. However, for custom or non-standard attributes that do not have direct property mappings, .attr is necessary to bind directly to the attribute itself.

    • Example: Binding id Using Property and Attribute

      Result:

      • Using .bind, Aurelia binds to the id property of the input element.

  • Choosing Between Interpolation and Keyword Binding

    Both interpolation and keyword binding can achieve similar outcomes. The choice between them often comes down to preference and specific use case requirements.

    • Performance and Features: There is no significant performance difference between the two. Both are equally efficient and offer similar capabilities.

    • Readability and Maintainability: Interpolation can be more readable for simple string concatenations, while keyword bindings offer more explicit control for complex bindings.

  • Use .from-view for write-only scenarios

    Handle null/undefined values gracefully
  • Consider performance implications of complex expressions

  • Follow accessibility best practices

  • Use TypeScript for better development experience

  • <!-- 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>
    HTML Attributes Referencearrow-up-right
    event binding guide

    Quick Reference ("How Do I...")

    Navigate your Aurelia 2 application with confidence using this task-focused quick reference.

    hashtag
    Table of Contents

    • Getting Started


    hashtag
    Getting Started

    hashtag
    How do I install and configure the router?

    hashtag
    How do I define routes?

    hashtag
    How do I set up a viewport?

    hashtag
    How do I use hash-based routing instead of clean URLs?


    hashtag
    Navigation

    hashtag
    How do I create navigation links?

    hashtag
    How do I navigate programmatically?

    hashtag
    How do I highlight the active link?

    hashtag
    How do I navigate to parent routes from nested components?

    hashtag
    How do I pass query parameters?

    hashtag
    How do I handle external links?

    Good news: External links work automatically! The router automatically ignores:

    Only use external attribute for edge cases:

    How it works: The router uses the URL constructor to check if a link is external. Any URL that can be parsed without a base (like https://, mailto:, etc.) is automatically treated as external.


    hashtag
    Route Parameters

    hashtag
    How do I define route parameters?

    hashtag
    How do I access route parameters in my component?

    hashtag
    How do I get all parameters including from parent routes?

    hashtag
    How do I constrain parameters with regex?


    hashtag
    Route Protection

    hashtag
    How do I protect routes (authentication)?

    hashtag
    How do I implement authorization (role-based access)?

    hashtag
    How do I prevent navigation away from unsaved forms?

    hashtag
    How do I redirect based on conditions?


    hashtag
    Lifecycle Hooks

    hashtag
    How do I load data before showing a component?

    hashtag
    How do I run code after a component is fully loaded?

    hashtag
    When do lifecycle hooks run?

    Hook
    When
    Use For

    hashtag
    What's the difference between component hooks and router hooks?

    • Component hooks (IRouteViewModel): Implemented on the component itself

    • Router hooks (@lifecycleHooks()): Shared across multiple components


    hashtag
    Advanced Topics

    hashtag
    How do I handle 404 / unknown routes?

    hashtag
    How do I create route aliases / redirects?

    hashtag
    How do I work with multiple viewports (sibling routes)?

    hashtag
    How do I implement nested/child routes?

    hashtag
    How do I lazy load routes?

    hashtag
    How do I set/change the page title?

    |

    hashtag
    How do I generate URLs without navigating?

    hashtag
    How do I work with base paths (multi-tenant apps)?

    hashtag
    How do I handle browser back/forward buttons?

    hashtag
    How do I access the current route information?


    hashtag
    Troubleshooting

    hashtag
    My routes don't work with clean URLs (no hash)

    Problem: Getting 404 errors when refreshing or accessing routes directly

    Solution:

    1. Ensure <base href="/"> is in your HTML

    2. Configure server for SPA routing (return index.html for all routes)

    3. Or use hash routing: useUrlFragmentHash: true

    hashtag
    External links are triggering the router (rare)

    Problem: External links somehow being handled by router

    This should NOT happen - the router automatically ignores external links like https://, mailto:, tel:, etc.

    If it's happening:

    1. Check your link format - is it truly external?

    2. You probably don't need the external attribute anymore

    3. Links with protocol (https://, mailto:) are automatically bypassed

    Only needed for edge cases:

    hashtag
    Navigation isn't working from nested components

    Problem: Links to sibling routes not working

    Solution: Use ../ prefix for parent context

    hashtag
    My lifecycle hooks aren't being called

    Problem: canLoad, loading, etc. not executing

    Solution: Implement the IRouteViewModel interface

    hashtag
    Route parameters aren't updating when navigating between same routes

    Problem: Navigating from /users/1 to /users/2 doesn't update component

    Solution: Configure transition plan

    hashtag
    How do I debug routing issues?


    hashtag
    Complete Documentation

    hashtag
    Getting Started

    hashtag
    Routes and Navigation

    hashtag
    Lifecycle and Guards

    hashtag
    State and Events

    hashtag
    Advanced Topics

    hashtag
    Reference

    Shopping Cart

    A complete shopping cart implementation with add/remove items, quantity updates, and dynamic total calculations. Demonstrates reactive data management and user interaction patterns.

    hashtag
    Features Demonstrated

    • Array manipulation - Add, remove, update cart items

    • Lambda expressions - Complex calculations directly in templates using reduce, filter, etc.

    • Event handling - Button clicks, quantity changes

    • Conditional rendering - Empty cart state, checkout button

    • List rendering with keys - Efficient cart item updates

    • Two-way binding - Quantity inputs

    • Number formatting - Currency display

    • Component state management - Cart as a service

    hashtag
    Code

    hashtag
    View Model (shopping-cart.ts)

    hashtag
    Currency Value Converter (currency-value-converter.ts)

    hashtag
    Template (shopping-cart.html)

    hashtag
    Styles (shopping-cart.css)

    hashtag
    How It Works

    hashtag
    1. Lambda Expressions in Templates

    Instead of computed properties in the view model, calculations are done directly in the template using lambda expressions:

    Aurelia's lambda expressions support complex operations like reduce, filter, map, every, and some directly in templates. The template automatically tracks dependencies and recalculates when cartItems changes.

    hashtag
    2. Array Manipulation

    Using array methods ensures change detection:

    hashtag
    3. Benefits of Lambda Expressions

    Moving calculations to the template has several advantages:

    • Reduced boilerplate - No need for getter methods in the view model

    • Clear intent - Calculations are visible right where they're used

    • Single source of truth - The template directly expresses what data it needs

    This approach is particularly useful for derived data that's only needed in the view.

    hashtag
    4. Efficient List Updates

    Using key: id allows Aurelia to track items efficiently:

    When items are removed or reordered, Aurelia reuses DOM elements.

    hashtag
    5. Quantity Validation

    Multiple ways to update quantity with validation:

    hashtag
    6. Conditional Rendering

    Show different UI based on cart state:

    Lambda expressions work seamlessly with conditionals: if.bind="cartItems.length" or if.bind="!cartItems.length".

    hashtag
    7. Currency Formatting with Value Converter

    The currency value converter formats prices consistently:

    The converter uses Intl.NumberFormat for proper currency formatting including the currency symbol, decimal places, and thousands separators. This keeps formatting logic out of the view model.

    hashtag
    8. When to Use Lambda Expressions vs Computed Properties

    Use lambda expressions in templates when:

    • The calculation is only needed in the view

    • The logic is straightforward and readable inline

    • You want to reduce view model boilerplate

    Use computed properties in the view model when:

    • The calculation is complex and would make the template hard to read

    • The value is used in multiple places (template and view model logic)

    • You need to unit test the calculation logic

    • The calculation is expensive and you want explicit memoization

    For this shopping cart example, the calculations are simple arithmetic operations that are only displayed to the user, making lambda expressions a great fit. They eliminate boilerplate while keeping the template clear and maintainable.

    hashtag
    Variations

    hashtag
    Persist Cart to LocalStorage

    hashtag
    Add Discount Codes

    hashtag
    Cart as a Service

    Make the cart available throughout the app:

    hashtag
    Related

    List Rendering

    Master list rendering in Aurelia with repeat.for. Learn efficient data binding, performance optimization, advanced patterns, and real-world techniques for dynamic collections including arrays, maps, s

    The repeat.for binding is Aurelia's powerful list rendering mechanism that creates highly optimized, reactive displays of collection data. It intelligently tracks changes, minimizes DOM updates, and provides rich contextual information for sophisticated data presentation.

    hashtag
    Core Concepts

    Using .attr, Aurelia binds directly to the id attribute in the DOM.

    After render

    Analytics, scroll, post-render effects

    canUnload

    Before deactivation

    Unsaved changes warnings

    unloading

    Before removal

    Cleanup, save drafts

    Viewports

  • Child Routing Playbook

  • canLoad

    Before activation

    Guards, redirects, param validation

    loading

    After approval, before render

    Data fetching, state setup

    Navigation
    Route Parameters
    Route Protection
    Lifecycle Hooks
    Advanced Topics
    Troubleshooting
    Full configuration options →
    Configuring routes →
    Viewports documentation →
    Hash vs PushState routing →
    Navigation methods →
    Using the Router API →
    Active CSS class →
    Ancestor navigation →
    Query parameters →
    Bypassing the router →
    Path and parameters →
    Lifecycle hooks →
    Aggregate parameters →
    Route parameters guide →
    Constrained parameters →
    Router hooks →
    Router hooks example →
    canUnload hook →
    Redirect from canLoad →
    loading hook →
    loaded hook →
    Hook summary →
    Router hooks vs component hooks →
    Fallback configuration →
    Redirects →
    Sibling viewports →
    Hierarchical routing →
    Child routing playbook →
    Using inline import() →
    Setting titles →
    Customizing titles →
    Path generation →
    Base path configuration →
    History strategy →
    Current route →
    PushState configuration →
    Bypassing href →
    Ancestor navigation →
    Lifecycle hooks →
    Transition plans →
    Router events →
    Getting Started
    Router Configuration
    Configuring Routes
    Route Parameters Guide
    Route Expression Syntaxarrow-up-right
    Navigation
    Lifecycle Hooks
    Router Hooks
    Transition Plans
    Current Route
    Navigation Model
    Router Events
    Error Handling
    Testing Guide
    API Referencearrow-up-right
    Troubleshooting

    loaded

    Automatic reactivity - Aurelia tracks all dependencies within lambda expressions

    Value Converters

    Product Catalog Recipe
    Lambda Expressions Guide
    List Rendering Guide
    Computed Properties
    <!-- 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);
      }
    }
    hashtag
    The repeat.for Binding

    repeat.for creates a template instance for each item in a collection, similar to a for...of loop but with intelligent DOM management:

    JavaScript Analogy:

    hashtag
    Change Detection and Updates

    Aurelia automatically observes collection changes and updates the DOM efficiently:

    Important: Use array mutating methods (push, pop, splice, reverse, sort) for automatic detection. Direct index assignment works but requires the array reference to change for detection.

    hashtag
    Performance Optimization with Keys

    hashtag
    Why Keys Matter

    Without keys, Aurelia recreates DOM elements when collections change. With keys, it reuses existing elements:

    hashtag
    Key Strategies

    Property-based keys (recommended):

    Literal property keys (more efficient):

    Expression-based keys (flexible but slower):

    hashtag
    When to Use Keys

    • Dynamic collections where items are added, removed, or reordered

    • Form inputs to preserve user input during updates

    • Stateful components to maintain component state

    • Large lists for performance optimization

    • Sortable/filterable lists

    Avoid keys when:

    • Collection is static or append-only

    • Items are simple primitives without DOM state

    • Performance testing shows no benefit

    hashtag
    Contextual Properties

    Every repeat iteration provides rich contextual information:

    hashtag
    Complete Property Reference

    Property
    Type
    Description

    $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    true for the first item

    hashtag
    Nested Repeats and $parent

    Access parent contexts in nested structures:

    hashtag
    Accessing Previous Items with $previous

    The $previous contextual property provides access to the previous iteration's item, enabling powerful comparison and rendering patterns. It is a computed property available by default as part of repeat's contextual values. You can disable all contextual computed values (including $previous) using the contextual option.

    Basic usage:

    Key characteristics:

    • $previous is null for the first item

    • $previous is undefined when contextual is disabled

    • Computed property with minimal overhead when enabled (contextual is enabled by default)

    • Works with all collection types (arrays, Maps, Sets, etc.)

    • Compatible with keyed repeats

    hashtag
    Section Headers and Dividers

    A common use case is rendering section headers only when data changes:

    Output:

    hashtag
    Comparison and Change Indicators

    Highlight changes from previous values:

    hashtag
    Combining with Keys

    $previous works seamlessly with keyed repeats:

    hashtag
    Conditional Contextual Properties

    Control contextual computed properties (including $previous) based on view model properties:

    hashtag
    Performance Considerations

    When contextual is disabled:

    • Zero memory overhead - $previous is not computed

    • Negligible CPU cost - single conditional check per item

    When contextual is enabled (default):

    • Computed on demand via contextual getter

    • Minimal CPU cost

    Best practices:

    • Keep contextual enabled unless you have a strong reason to disable it

    • If needed, disable per-instance with contextual: false or contextual.bind: someBoolean

    hashtag
    Data Types and Collections

    hashtag
    Arrays

    The most common and optimized collection type:

    hashtag
    Sets

    Useful for unique collections:

    hashtag
    Maps

    Perfect for key-value pairs:

    hashtag
    Number Ranges

    Generate sequences quickly:

    hashtag
    Advanced Patterns

    hashtag
    Destructuring Declarations

    Extract multiple values in the repeat declaration:

    hashtag
    Integration with Other Template Controllers

    Conditional rendering within repeats:

    Nested conditionals and repeats:

    hashtag
    Working with Async Data

    Handle loading states and async operations:

    hashtag
    Complex Object Iteration

    Use value converters for non-standard collections:

    hashtag
    Performance Best Practices

    hashtag
    Optimizing Large Lists

    Use keyed iteration:

    Consider virtual scrolling for very large lists:

    This requires using the virtual repeat plugin.

    hashtag
    Memory Management

    Avoid memory leaks in complex scenarios:

    hashtag
    Custom Collection Handlers

    hashtag
    Built-in Handlers

    Aurelia includes handlers for:

    • Arrays (Array, [])

    • Sets (Set)

    • Maps (Map)

    • Numbers (5 → creates range 0-4)

    • Array-like objects (NodeList, HTMLCollection, etc.)

    • Null/undefined (renders nothing)

    hashtag
    Creating Custom Handlers

    For specialized collections:

    hashtag
    Observable Collections

    Create reactive custom collections:

    hashtag
    Troubleshooting Common Issues

    hashtag
    Issue: Changes Not Reflecting

    Problem: Direct array index assignment doesn't trigger updates

    Solution: Use array methods or replace the array

    hashtag
    Issue: Form State Lost on Reorder

    Problem: Input values disappear when list is reordered

    Solution: Use stable keys

    hashtag
    Issue: Performance with Large Lists

    Problem: Slow rendering with 1000+ items

    Solutions:

    1. Use virtual scrolling for very large lists

    2. Implement pagination or infinite scroll

    3. Optimize templates - minimize complex expressions

    4. Use keys to enable DOM reuse

    hashtag
    Issue: Memory Leaks

    Problem: Components not disposing properly

    Solution: Clean up in lifecycle hooks

    hashtag
    Real-World Examples

    hashtag
    Dynamic Product Catalog

    hashtag
    Data Table with Sorting

    hashtag
    TypeScript Integration

    hashtag
    Type-Safe Repeats

    <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 Handbook
    export 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>

    Shadow DOM

    Learn how to use Shadow DOM in Aurelia components for style encapsulation and native web component features.

    Shadow DOM provides native browser encapsulation for your components, isolating styles and DOM structure. Aurelia makes it easy to enable Shadow DOM for any custom element.

    hashtag
    Quick decision guide

    Start with Light DOM unless you have a specific reason to enable Shadow DOM. Shadow DOM isolates styles by design, so global CSS frameworks and app-wide themes do not flow into components unless you explicitly share them. You can mix Light DOM and Shadow DOM in the same app—use Shadow DOM only on components that need strict isolation or native web component features.

    hashtag
    Enabling Shadow DOM

    hashtag
    Using the @useShadowDOM Decorator

    The simplest way to enable Shadow DOM is with the @useShadowDOM decorator:

    By default, this creates a shadow root with mode: 'open'.

    hashtag
    Configuring Shadow DOM Mode

    Shadow DOM supports two modes: open and closed.

    Open mode (default) allows external JavaScript to access the shadow root:

    Closed mode prevents external access to the shadow root:

    Mode only controls JavaScript access to the shadow root. It does not change CSS encapsulation—global styles still will not cross the shadow boundary in open mode.

    hashtag
    Using the Configuration Object

    You can also configure Shadow DOM using the @customElement decorator's configuration object:

    Or using a static property:

    hashtag
    Styling Shadow DOM Components

    Shadow DOM provides complete CSS isolation. Styles defined outside the component won't affect elements inside, and styles inside won't leak out.

    hashtag
    Troubleshooting checklist

    If styles or slots are not behaving as expected, check these first:

    • Confirm the component actually uses Shadow DOM: @useShadowDOM() or shadowOptions must be set.

    • Global CSS won’t cross the boundary: Use Light DOM for framework styles, or register shared styles with StyleConfiguration.shadowDOM({ sharedStyles: [...] }).

    hashtag
    Component-Local Styles

    Use the shadowCSS helper to register styles for your component:

    hashtag
    Using Constructable Stylesheets

    For better performance and reusability, you can pass CSSStyleSheet instances:

    hashtag
    Global Shared Styles

    Configure styles that apply to all Shadow DOM components in your application:

    Global styles are applied first, followed by component-local styles.

    Shared styles only apply to components that actually use Shadow DOM. They do not affect Light DOM components, and selectors like html or body still cannot reach into a shadow root. If you want a global CSS framework to style Shadow DOM components, import that stylesheet and include it in sharedStyles, or keep those components in Light DOM.

    hashtag
    Shadow DOM CSS Selectors

    Shadow DOM provides special CSS selectors for enhanced styling control:

    hashtag
    The :host Selector

    Style the component's host element from within the shadow root:

    hashtag
    The :host-context() Selector

    Style the host based on an ancestor's context:

    hashtag
    The ::slotted() Selector

    Style content that has been projected into a slot:

    circle-exclamation

    Important: The ::slotted() selector only works on direct children of the slot. It cannot select nested descendants within slotted content.

    hashtag
    The ::part() Selector

    Expose specific elements for external styling using part attributes:

    circle-info

    The ::part() selector is the recommended way to create styling hooks for consumers of your components. It provides explicit control over which internal elements can be styled externally.

    hashtag
    Styling from Outside: CSS Custom Properties

    The most flexible way to style Shadow DOM components from outside is using CSS custom properties (CSS variables):

    hashtag
    Using CSS Modules with Shadow DOM

    CSS Modules provide class name transformation for avoiding naming conflicts. You can combine cssModules() with Shadow DOM for both style encapsulation and class name scoping:

    circle-info

    How it works: cssModules() transforms class names in your template at compile time, while shadowCSS() injects the actual CSS into the shadow root. When using CSS Modules with Shadow DOM, ensure your CSS rules use the transformed class names.

    circle-exclamation

    Note: CSS Modules mappings do not inherit to child components. Each component must register its own cssModules() dependency.

    hashtag
    Shadow DOM and Slots

    Native <slot> elements require Shadow DOM. Attempting to use <slot> without Shadow DOM will throw a compilation error.

    hashtag
    Basic Slot Usage

    Usage:

    hashtag
    Named Slots

    Usage:

    hashtag
    Fallback Content

    Slots can have default content when nothing is projected:

    hashtag
    Listening to Slot Changes

    React to changes in slotted content:

    For more advanced slot usage, including the @children decorator and component view model retrieval, see the .

    hashtag
    Constraints and Limitations

    hashtag
    Cannot Combine with @containerless

    Shadow DOM requires a host element to attach to. You cannot use both @useShadowDOM and @containerless on the same component:

    Error: Invalid combination: cannot combine the containerless custom element option with Shadow DOM.

    hashtag
    Native Slots Require Shadow DOM

    Using <slot> elements without enabling Shadow DOM will cause a compilation error:

    Error: Template compilation error: detected a usage of "<slot>" element without specifying shadow DOM options in element: broken-component

    Solution: Either enable Shadow DOM or use <au-slot> instead:

    hashtag
    Choosing Between Shadow DOM and Light DOM

    hashtag
    Use Shadow DOM When:

    • Style isolation is critical: You need to prevent external styles from affecting your component

    • Building reusable components: Your component will be used in different contexts and needs predictable styling

    • Using native web component features: You need features like <slot>, CSS :host selector, or

    hashtag
    Use Light DOM (no Shadow DOM) When:

    • Easy styling is important: Parent components or application styles should easily affect the component

    • Working with global styles: You rely on application-wide styles or CSS frameworks (Bulma/Bootstrap/Tailwind) to flow into components

    • SEO is a concern: Search engines can more easily index light DOM content

    hashtag
    Practical Examples

    hashtag
    Themed Button Component

    hashtag
    Card with Multiple Slots

    hashtag
    Component with Dynamic Styles

    hashtag
    Best Practices

    hashtag
    1. Use CSS Custom Properties for Theming

    Allow users to customize your components through CSS variables with sensible defaults:

    hashtag
    2. Provide Fallback Content for Slots

    Give users a good default experience even when they don't provide slot content:

    hashtag
    3. Namespace Your CSS Variables

    Prevent naming conflicts by prefixing your component's CSS variables:

    hashtag
    4. Consider Performance with Constructable Stylesheets

    For optimal performance, Aurelia uses when supported by the browser, falling back to <style> elements otherwise.

    Automatic caching: When you pass CSS strings to shadowCSS(), Aurelia automatically caches the compiled CSSStyleSheet instances. This means multiple instances of the same component share the same stylesheet object in memory.

    For maximum control, you can create CSSStyleSheet objects directly:

    circle-info

    Using pre-created CSSStyleSheet objects is slightly more efficient than CSS strings because it skips the string-to-stylesheet conversion step, though Aurelia's caching makes this difference minimal for most applications.

    hashtag
    5. Use Open Mode Unless You Have a Reason Not To

    Closed mode prevents useful debugging and testing. Use open mode by default:

    hashtag
    6. Document Your CSS Custom Properties

    If your component supports theming, document the available CSS variables:

    hashtag
    7. Convention-Based CSS Does Not Auto-Inject into Shadow DOM

    Aurelia's convention-based CSS loading (where my-component.css is auto-imported alongside my-component.ts) does not automatically inject styles into Shadow DOM. For Shadow DOM components, you must explicitly use shadowCSS():

    circle-info

    Convention-based CSS loading works well for Light DOM components where styles are added to the document. For Shadow DOM components, always use shadowCSS() to ensure styles are properly scoped within the shadow root.

    hashtag
    Additional Resources

    • - Deep dive into slots, @children, and @slotted decorators

    • - Using Aurelia components as web components

    • - Complete API documentation including Shadow DOM options

    Cheat Sheet

    Quick reference guide for Aurelia 2 templating syntax and common patterns.

    hashtag
    Data Binding

    hashtag
    Binding Modes

    Syntax
    Direction
    Use Case

    hashtag
    Common Bindings

    hashtag
    String Interpolation

    hashtag
    Event Binding

    hashtag
    Basic Events

    hashtag
    Event Modifiers

    hashtag
    Available Key Modifiers

    Category
    Modifiers

    Combine with +: keydown.trigger:ctrl+shift+enter="handler()"

    hashtag
    Binding Behaviors

    hashtag
    Conditional Rendering

    hashtag
    if.bind vs show.bind vs hide.bind

    circle-info

    hide.bind is an alias for show.bind with inverted logic. hide.bind="true" is equivalent to show.bind="false".

    hashtag
    switch.bind

    hashtag
    List Rendering

    hashtag
    Basic Syntax

    hashtag
    Contextual Properties

    Property
    Type
    Description

    hashtag
    Advanced Collection Types

    hashtag
    Value Converters

    hashtag
    Syntax

    hashtag
    Common Built-in Patterns

    hashtag
    Template References

    Ref Type
    Returns
    Use Case

    hashtag
    Template Variables

    circle-info

    By default, <let> creates template-local variables. Add to-binding-context to assign values directly to the view model instead.

    hashtag
    Class & Style Binding

    hashtag
    Attribute Binding

    hashtag
    Promises in Templates

    hashtag
    Spread Binding

    circle-info

    Spread binding is always one-way (to-view). Only properties that exist in the object at evaluation time create bindings.

    hashtag
    Custom Attributes

    hashtag
    Component Import & Usage

    hashtag
    Dynamic Composition

    hashtag
    Focus Management

    hashtag
    Quick Decision Trees

    hashtag
    When to use if vs show?

    hashtag
    Which binding mode?

    hashtag
    When to use keys in repeat.for?

    hashtag
    Common Patterns

    hashtag
    Loading States

    hashtag
    Form Validation Display

    hashtag
    Computed Display Values

    hashtag
    Dynamic CSS Classes

    hashtag
    Component Lifecycle (Quick Reference)

    Hook
    When Called
    Common Use

    hashtag
    Performance Tips

    1. Use appropriate binding modes: .to-view for display-only data

    2. Add keys to repeat.for: Enables efficient DOM reuse

    3. Use show.bind for frequent toggles: Avoids DOM manipulation overhead

    hashtag
    Common Gotchas

    Issue
    Problem
    Solution

    hashtag
    Accessibility Reminders

    hashtag
    Related Documentation

    Data Table

    A complete, production-ready data table with sorting, filtering, pagination, row selection, and responsive design.

    hashtag
    Features Demonstrated

    • Two-way data binding - Search input, filters, page size

    • Computed properties - Filtered, sorted, and paginated data

    • repeat.for with keys - Efficient list rendering with tracking

    • Event handling - Sort, filter, pagination clicks

    • Conditional rendering - Empty states, loading states

    • Value converters - Date and number formatting

    • CSS class binding - Active sort, selected rows

    • Debouncing - Optimize search performance

    hashtag
    Code

    hashtag
    View Model (data-table.ts)

    hashtag
    Template (data-table.html)

    hashtag
    Styles (data-table.css)

    hashtag
    How It Works

    hashtag
    Filtering Pipeline

    Data flows through a pipeline:

    1. Raw data (allUsers) → all records

    2. Filtered (filteredUsers) → apply search and dropdown filters

    3. Sorted (sortedUsers) → apply column sorting

    Each computed property builds on the previous one, keeping the logic clean and testable.

    hashtag
    Sorting

    Click column headers to sort. The first click sorts ascending, the second descending, and subsequent clicks toggle between the two. The active sort column is highlighted.

    hashtag
    Pagination

    Smart pagination shows up to 5 page numbers with ellipsis for gaps. Always shows first and last pages. Automatically adjusts when filters reduce total pages.

    hashtag
    Selection

    • Checkbox in header selects/deselects all rows on current page

    • Individual row checkboxes for granular selection

    • Selected rows track across pages

    • Delete selected button removes all selected users

    hashtag
    Performance

    • Debounced search (300ms) prevents excessive filtering

    • Keyed repeat ensures efficient DOM updates

    • Computed properties cache results until dependencies change

    hashtag
    Variations

    hashtag
    Server-Side Pagination

    For large datasets, move filtering/sorting to the server:

    hashtag
    Inline Editing

    Add edit mode for quick updates:

    hashtag
    Column Visibility Toggle

    Let users show/hide columns:

    hashtag
    Related

    • - Another filtering/sorting example

    • - repeat.for documentation

    • - if.bind and show.bind

    CustomElement API

    The CustomElement resource is a fundamental concept in Aurelia 2, providing the core functionality for creating encapsulated and reusable components. This comprehensive guide covers all aspects of the CustomElement API, including methods, decorators, and configuration options.

    hashtag
    Table of Contents

    $last

    boolean

    true for the last item

    $middle

    boolean

    true for items that aren't first or last

    $even

    boolean

    true for even indices (0, 2, 4...)

    $odd

    boolean

    true for odd indices (1, 3, 5...)

    $length

    number

    Total number of items

    $previous

    any

    null

    $parent

    object

    Parent binding context

    Co-located CSS is not auto-injected: Import CSS as a string and pass it to shadowCSS() for Shadow DOM components.
  • Use Shadow DOM selectors: :host, :host-context(), and ::slotted() apply inside the shadow root. Use CSS variables or ::part for safe theming.

  • Slots require Shadow DOM: Native <slot> only works with Shadow DOM; use <au-slot> if you stay in Light DOM.

  • Containerless is incompatible: You cannot use Shadow DOM and @containerless together.

  • Debugging needs open mode: mode: 'open' makes it easier to inspect and tweak styles in DevTools.

  • ::part
  • Creating a design system: Components should maintain consistent appearance regardless of environment

  • Using <au-slot>: You need Aurelia's slot features like $host scope access
    Slotted Content documentation
    Constructable Stylesheetsarrow-up-right
    Slotted Content Documentation
    Web Components Documentation
    CustomElement API Reference

    Form inputs requiring sync

    .from-view

    View → View Model

    Capture user input only

    .one-time

    View Model → View (once)

    Static data that never changes

    a-z (case-insensitive)

    Numbers

    0-9

    Event control

    prevent, stop

    Mouse buttons

    left, middle, right

    Cleans up events/bindings

    Keeps everything in memory

    Use When

    Content rarely changes

    Content toggles frequently

    boolean

    True for last item

    $middle

    boolean

    True for middle items

    $even

    boolean

    True for even indices

    $odd

    boolean

    True for odd indices

    $length

    number

    Total number of items

    $parent

    object

    Parent binding context

    $previous

    any

    Previous item (when contextual enabled)

    Controller instance

    Advanced lifecycle access

    my-attr.ref

    Custom attribute instance

    Access attribute methods

    Before bindings evaluated

    Initialize from bindables

    bound

    Bindings connected

    Setup dependent on bound values

    attaching

    Before DOM attachment

    Async setup work

    attached

    In DOM

    DOM manipulation, third-party libs

    detaching

    Before DOM removal

    Cleanup, save state

    unbinding

    Bindings disconnecting

    Cleanup subscriptions

    Use if.bind for infrequent changes: Saves memory and resources

  • Debounce/throttle rapid events: Prevents excessive handler calls

  • Keep expressions simple: Move complex logic to view model

  • Use value converters: Separate formatting from view model logic

  • CSS property name

    Use kebab-case: background-color not backgroundColor

    Async data undefined

    Template renders before data

    Use promise.bind or if.bind="data"

    $parent undefined

    Not in a repeat

    Only available inside repeat.for

    List Rendering

  • Value Converters

  • Binding Behaviors

  • Form Inputs

  • Class & Style Binding

  • Spread Binding

  • Local Templates

  • Lambda Expressions

  • .bind

    Auto (two-way for form elements, to-view otherwise)

    Default choice for most scenarios

    .to-view

    View Model → View

    Display-only data (performance)

    .two-way

    Meta keys

    ctrl, alt, shift, meta

    Special keys

    enter, escape, space, tab

    Arrow keys

    arrowup, arrowdown, arrowleft, arrowright

    Feature

    if.bind

    show.bind / hide.bind

    DOM Manipulation

    Adds/removes from DOM

    Shows/hides (display: none)

    Performance

    Better for infrequent changes

    Better for frequent toggles

    $index

    number

    Zero-based index (0, 1, 2...)

    $first

    boolean

    True for first item

    ref

    HTMLElement

    DOM manipulation

    component.ref

    View model instance

    Call component methods

    constructor

    Instance created

    Inject dependencies with resolve()

    created

    After constructor

    Access $controller

    Binding not updating

    Object/array mutation

    Use splice(), reassign, or use observable

    Event not firing

    Wrong event name

    Check: click.trigger not onclick.trigger

    Template Syntax Overview
    Attribute Binding
    Event Binding
    Conditional Rendering

    View Model ↔ View

    Letters

    Resources

    $last

    controller.ref

    binding

    Style not applied

  • Paginated (paginatedUsers) → slice for current page

  • Value Converters - Date/number formatting

    Product Catalog
    List Rendering
    Conditional Rendering
    import { 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-time
    Using 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>
    Core Methods
    • CustomElement.for

    • CustomElement.define

    • CustomElement.getDefinition

    • CustomElement.find

  • Metadata Methods

    • CustomElement.annotate

    • CustomElement.getAnnotation

  • Utility Methods

    • CustomElement.generateName

    • CustomElement.generateType

    • CustomElement.createInjectable

  • Decorators

    • @customElement

    • @useShadowDOM

    • @containerless

  • Definition Objects

    • PartialCustomElementDefinition

    • CustomElementDefinition

  • hashtag
    Core Methods

    hashtag
    CustomElement.for

    Retrieves the Aurelia controller associated with a DOM node. The controller provides access to the element's view model, lifecycle methods, and other properties.

    hashtag
    Method Signatures

    hashtag
    Parameters

    • node: Node - The DOM node for which to retrieve the controller

    • opts?: object - Optional configuration object with the following properties:

      • optional?: boolean - If true, returns null instead of throwing when no controller is found

      • searchParents?: boolean - If true, searches parent nodes (including containerless elements) for a controller

      • name?: string - If provided, only returns controllers for custom elements with this specific name

    hashtag
    Examples

    hashtag
    CustomElement.define

    Registers a class as a custom element in Aurelia. This method can be called directly or is used internally by the @customElement decorator.

    hashtag
    Method Signatures

    hashtag
    Parameters

    • nameOrDef: string | PartialCustomElementDefinition - Either the element name or a complete definition object

    • Type?: Constructable - The class containing the element's logic (optional when using definition object)

    hashtag
    Examples

    hashtag
    CustomElement.getDefinition

    Retrieves the CustomElementDefinition for a custom element class, providing access to all metadata about the element.

    hashtag
    Method Signature

    hashtag
    Parameters

    • Type: Constructable - The custom element class

    hashtag
    Return Value

    Returns a CustomElementDefinition object containing all metadata about the custom element.

    hashtag
    Example

    hashtag
    CustomElement.find

    Searches for a custom element definition by name within a specific container's registry.

    hashtag
    Method Signature

    hashtag
    Parameters

    • container: IContainer - The dependency injection container to search in

    • name: string - The name of the custom element to find

    hashtag
    Return Value

    Returns the CustomElementDefinition if found, or null if no element with the specified name is registered.

    hashtag
    Example

    hashtag
    CustomElement.isType

    Checks whether a given value is a custom element type (class decorated with @customElement or defined via CustomElement.define).

    hashtag
    Method Signature

    hashtag
    Parameters

    • value: any - The value to check

    hashtag
    Return Value

    Returns true if the value is a custom element type, false otherwise.

    hashtag
    Example

    hashtag
    Metadata Methods

    hashtag
    CustomElement.annotate

    Attaches metadata to a custom element class. This is typically used internally by decorators and the framework.

    hashtag
    Method Signature

    hashtag
    Parameters

    • Type: Constructable - The custom element class to annotate

    • prop: string - The property key for the annotation

    • value: any - The value to associate with the property

    hashtag
    Example

    hashtag
    CustomElement.getAnnotation

    Retrieves metadata that was previously attached to a custom element class.

    hashtag
    Method Signature

    hashtag
    Parameters

    • Type: Constructable - The custom element class

    • prop: string - The property key to retrieve

    hashtag
    Return Value

    Returns the annotation value, or undefined if not found.

    hashtag
    Example

    hashtag
    Utility Methods

    hashtag
    CustomElement.generateName

    Generates a unique name for a custom element, useful for anonymous or dynamically created elements.

    hashtag
    Method Signature

    hashtag
    Return Value

    A string representing a unique name (typically in the format unnamed-{number}).

    hashtag
    Example

    hashtag
    CustomElement.generateType

    Dynamically generates a CustomElementType with a given name and prototype properties.

    hashtag
    Method Signature

    hashtag
    Parameters

    • name: string - The name for the generated type

    • proto?: object - An optional object containing properties and methods to add to the prototype

    hashtag
    Return Value

    A CustomElementType that can be used with CustomElement.define.

    hashtag
    Example

    hashtag
    CustomElement.createInjectable

    Creates an InjectableToken for dependency injection scenarios.

    hashtag
    Method Signature

    hashtag
    Return Value

    An InterfaceSymbol that can be used as a dependency injection token.

    hashtag
    Example

    hashtag
    CustomElement.keyFrom

    Generates the registry key used internally to store and retrieve custom element definitions.

    hashtag
    Method Signature

    hashtag
    Parameters

    • name: string - The custom element name

    hashtag
    Return Value

    A string representing the internal registry key.

    hashtag
    Example

    hashtag
    Decorators

    hashtag
    @customElement Decorator

    The primary decorator for marking a class as a custom element.

    hashtag
    Syntax

    hashtag
    Examples

    hashtag
    @useShadowDOM Decorator

    Enables Shadow DOM for the custom element.

    hashtag
    Syntax

    hashtag
    Example

    hashtag
    @containerless Decorator

    Renders the custom element without its element container.

    hashtag
    Syntax

    hashtag
    Example

    hashtag
    @capture Decorator

    Enables capturing of all attributes and bindings that are not explicitly defined as bindables or template controllers.

    hashtag
    Syntax

    hashtag
    Example

    hashtag
    @processContent Decorator

    Defines a hook that processes the element's content before compilation.

    hashtag
    Syntax

    hashtag
    Example

    hashtag
    Definition Objects

    hashtag
    PartialCustomElementDefinition

    An object that describes a custom element's configuration. All properties are optional.

    hashtag
    Properties

    hashtag
    Example

    hashtag
    CustomElementDefinition

    The complete, resolved definition of a custom element (read-only).

    hashtag
    Key Properties

    hashtag
    Programmatic Resource Aliases

    PartialCustomElementDefinition.aliases is only one way to expose alternative names. For reusable libraries or bridge packages you often need to add aliases outside of the definition itself. The runtime provides two helpers to make that ergonomic.

    hashtag
    alias(...aliases) decorator

    Apply the decorator directly to any custom element, custom attribute, value converter, or binding behavior to append aliases to the resource metadata.

    The decorator merges with aliases declared via the definition object, so you can sprinkle default aliases in a base class and extend them in derived implementations without clobbering earlier metadata.

    hashtag
    registerAliases(...)

    When you need to attach aliases to an existing resource (for example, to keep backwards compatibility after a rename), call registerAliases during app startup.

    The resource argument identifies which registry to update. Pass CustomElement, CustomAttribute, ValueConverter, or BindingBehavior depending on the resource you are aliasing. Because aliases are registered against the supplied container you can scope them to individual feature modules or make them global by running the task in your root configuration.

    hashtag
    Best Practices

    hashtag
    1. Use Decorators Over Direct API Calls

    hashtag
    2. Type Your Controllers

    hashtag
    3. Handle Errors Gracefully

    hashtag
    4. Leverage Definition Objects for Complex Elements

    // 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> | undefined
    import { 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 | null
    import { 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]
    ): void
    import { 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] | undefined
    import { 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(): string
    import { 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): string
    import { 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 method
    import { 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 {}

    Binding behaviors

    Binding behaviors are a powerful category of view resources in Aurelia 2 that modify how bindings operate. Unlike value converters which transform data, binding behaviors have complete access to the binding instance throughout its entire lifecycle, allowing them to fundamentally alter binding behavior.

    hashtag
    Overview

    Binding behaviors enable you to:

    • Control timing - throttle, debounce, or trigger updates at specific intervals

    • Modify binding modes - force one-way, two-way, or one-time binding behavior

    • Customize event handling - filter events or change which events trigger updates

    • Add debugging capabilities - inspect, log, or visualize binding behavior

    • Implement complex logic - create reusable binding modifications

    hashtag
    Syntax

    Binding behaviors use the & operator and follow similar syntax to value converters:

    Parameter syntax flexibility:

    hashtag
    Throttle

    Aurelia provides several built-in binding behaviors to address common scenarios. The throttle behavior is designed to limit the rate at which updates propagate. This can apply to updates from the view-model to the view (in to-view or one-way bindings) or from the view to the view-model (in two-way bindings).

    By default, throttle enforces a minimum time interval of 200ms between updates. You can easily customize this interval.

    Here are some practical examples:

    Limiting property updates to a maximum of once every 200ms

    In this example, the searchQuery property in your view model will update at most every 200ms, even if the user types more rapidly in the input field. This is especially useful for search inputs or other scenarios where frequent updates can be inefficient or overwhelming.

    You'll notice the & symbol, which is used to introduce binding behavior expressions. The syntax for binding behaviors mirrors that of value converters:

    • Arguments: Binding behaviors can accept arguments, separated by colons: propertyName & behaviorName:arg1:arg2.

    • Chaining: Multiple binding behaviors can be chained together: propertyName & behavior1 & behavior2:arg1.

    • Combined with Value Converters: Binding expressions can include both value converters and binding behaviors:

    Let's see how to customize the throttling interval:

    Limiting property updates to a maximum of once every 850ms

    The throttle behavior is particularly valuable when used with event bindings, especially for events that fire frequently, such as mousemove.

    Handling mousemove events at most every 200ms

    In this case, the mouseMoveHandler method in your view model will be invoked at most every 200ms, regardless of how frequently the mousemove event is triggered as the user moves their mouse.

    hashtag
    Flushing Pending Throttled Updates

    In certain situations, you might need to immediately apply any pending throttled updates. Consider a form with throttled input fields. When a user tabs out of a field after typing, you might want to ensure the latest value is immediately processed, even if the throttle interval hasn't elapsed yet.

    The throttle binding behavior supports this via a "signal". You can specify a signal name as the second argument to throttle. Then, using Aurelia's ISignaler, you can dispatch this signal to force a flush of the throttled update.

    In this example:

    • value.bind="formValue & throttle:200:'flushInput'": The formValue binding is throttled to 200ms and associated with the signal 'flushInput'.

    • blur.trigger="signaler.dispatchSignal('flushInput')": When the input loses focus (blur event), signaler.dispatchSignal('flushInput') is called. This immediately triggers any pending throttled update associated with the

    You can also specify multiple signals using an array:

    This allows multiple different signals to trigger the same throttled update, providing flexibility in complex scenarios where updates might need to be flushed from different parts of your application.

    hashtag
    Debounce

    The debounce binding behavior is another rate-limiting tool. debounce delays updates until a specified time interval has passed without any further changes. This is ideal for scenarios where you want to react only after a user has paused interacting.

    A classic use case is a search input that triggers an autocomplete or search operation. Making an API call with every keystroke is inefficient. debounce ensures the search logic is invoked only after the user has stopped typing for a moment.

    Updating a property after typing has stopped for 200ms

    Updating a property after typing has stopped for 850ms

    Similar to throttle, debounce is highly effective with event bindings.

    Calling mouseMoveHandler after the mouse stops moving for 500ms

    hashtag
    Flushing Pending Debounced Calls

    Like throttle, debounce also supports flushing pending updates using signals. This is useful in scenarios like form submission where you want to ensure the most recent debounced values are processed immediately, even if the debounce interval hasn't elapsed.

    In this example, the validateInput method (which could perform input validation or other actions) will be called when the input field loses focus, even if the 300ms debounce interval isn't fully over, ensuring timely validation.

    As with throttle, you can also provide multiple signal names to debounce:

    hashtag
    UpdateTrigger

    The updateTrigger binding behavior allows you to customize which DOM events trigger updates from the view to the view model for input elements. By default, Aurelia uses the change and input events for most input types.

    However, you can override this default behavior. For example, you might want to update the view model only when an input field loses focus (blur event).

    Updating the view model only on blur

    You can specify multiple events that should trigger updates:

    Updating the view model on blur or paste events

    This is useful in scenarios where you need fine-grained control over when view-model updates occur based on specific user interactions with input elements.

    hashtag
    Signal

    The signal binding behavior provides a mechanism to explicitly tell a binding to refresh itself. This is particularly useful when a binding's result depends on external factors or global state changes that Aurelia's observation system might not automatically detect.

    Consider a "translate" value converter that translates keys into localized strings, e.g., ${'greeting.key' | translate}. If your application allows users to change the language dynamically, how do you refresh all the translation bindings to reflect the new language?

    Another example is a value converter that displays a "time ago" string relative to the current time, e.g., Posted ${post.date | timeAgo}. As time progresses, this binding needs to refresh periodically to show updated relative times like "5 minutes ago," "an hour ago," etc.

    signal binding behavior solves these refresh scenarios:

    Using a Signal to Refresh Bindings

    In this example, signal:'time-update' assigns the signal name 'time-update' to this binding. Multiple bindings can share the same signal name.

    To trigger a refresh of all bindings with the signal name 'time-update', you use the ISignaler:

    Dispatching a Signal to Refresh Bindings

    Every 5 seconds, the setInterval function updates lastUpdated and then calls signaler.dispatchSignal('time-update'). This tells Aurelia to re-evaluate all bindings that are configured with & signal:'time-update', causing them to refresh and display the updated "time ago" value.

    hashtag
    Binding Mode Behaviors

    Aurelia exposes four mode behaviors in @aurelia/runtime-html (oneTime, toView, fromView, twoWay). Each one derives from the shared BindingModeBehavior base class which simply assigns a different binding.mode during bind and restores it on unbind. They are especially handy when:

    • You are consuming a component whose bindable defaults to two-way, but a specific usage should stay strictly view-model → view.

    • You want to keep .bind syntax but override the direction inside repeat.for, if/else, or other inline templates without changing the child API.

    StandardConfiguration registers these behaviors for you. If you need something more custom—say, a behavior that forces BindingMode.twoWay only when the target implements a particular interface—you can extend BindingModeBehavior yourself:

    After registration you can apply &dirtyChecked in any binding expression.

    Aurelia provides binding behaviors that explicitly specify binding modes. While binding commands (.bind, .to-view, .two-way) are more commonly used, these behaviors offer programmatic control over binding modes.

    hashtag
    oneTime

    The oneTime binding behavior creates the most efficient binding by evaluating the expression only once and never observing it for changes.

    oneTime bindings eliminate observation overhead entirely, making them ideal for:

    • Static configuration values

    • IDs and other immutable data

    • Large lists where some properties never change

    • Performance-critical rendering scenarios

    hashtag
    toView (One-Way)

    Forces one-way data flow from view-model to view only.

    hashtag
    fromView

    Forces one-way data flow from view to view-model only. The view-model property will be updated when the view changes, but view-model changes won't update the view.

    This is useful for scenarios like:

    • Collecting user input without reflecting programmatic changes back to the UI

    • One-way form submission scenarios

    • Performance optimization when you don't need view updates

    hashtag
    twoWay

    Forces bidirectional data synchronization between view and view-model.

    hashtag
    Binding Mode Summary

    Behavior
    Direction
    Use Case
    Command Equivalent
    circle-info

    Naming Convention: Binding mode behaviors use camelCase (toView, fromView, twoWay) because they're JavaScript expressions, while binding commands use dash-case (.to-view, .from-view, .two-way) due to HTML's case-insensitive nature.

    hashtag
    Self

    The self binding behavior is used in event bindings to ensure that the event handler only responds to events dispatched directly from the element the listener is attached to, and not from any of its descendant elements due to event bubbling.

    Consider a scenario with a panel component:

    Scenario without self binding behavior

    Without self, the onMouseDown handler will be invoked not only when the user mousedown on the <header> element itself, but also on any element inside the header, such as the "Settings" and "Close" buttons, due to event bubbling. This might not be the desired behavior if you want the panel to react only to direct interactions with the header, not its contents.

    You could handle this in your event handler by checking the event.target:

    Event Handler without self binding behavior (manual check)

    However, this mixes DOM event handling logic with component-specific behavior. The self binding behavior offers a cleaner, more declarative solution:

    Using self binding behavior

    Event Handler with self binding behavior

    By adding & self to the event binding, Aurelia ensures that onMouseDown is only called when the mousedown event originates directly from the <header> element, simplifying your event handler logic and separating concerns.

    hashtag
    Attr

    The attr binding behavior forces a binding to use attribute accessor instead of property accessor. This is particularly useful when working with custom attributes or when you need to ensure the HTML attribute is set rather than just the property.

    Forcing attribute binding:

    When to use attr:

    • Custom attributes that require actual HTML attributes to be set

    • Interoperability with third-party libraries that read HTML attributes

    • SEO considerations where attributes need to be present in the DOM

    Example with custom attribute:

    hashtag
    Custom Binding Behaviors

    You can create your own custom binding behaviors to encapsulate reusable binding modifications. Like value converters, custom binding behaviors are view resources.

    Custom binding behaviors implement bind(scope, binding, [...args]) and unbind(scope, binding) methods:

    • bind(scope, binding, [...args]): Called when the binding is created and attached to the DOM. This is where you implement the behavior modification.

      • scope: The binding's scope, providing access to the view model (scope.bindingContext) and override context (scope.overrideContext)

    Important: Note the parameter order - scope comes first, then binding. This is different from some other Aurelia lifecycle methods.

    Let's look at some practical examples of custom binding behaviors.

    hashtag
    Log Binding Context Behavior

    This behavior logs the current binding context to the browser's console every time the binding updates its target (view). This is invaluable for debugging and understanding data flow in your Aurelia application.

    Usage in Template:

    Now, whenever the userName binding updates the input element, you'll see the current binding context logged to the console, helping you inspect the data available at that point.

    hashtag
    Inspect Value Binding Behavior (Tooltip)

    This behavior adds a temporary tooltip to the element displaying the binding's current value whenever it updates. This offers a quick way to inspect binding values directly in the UI without resorting to console logs.

    Usage in Template:

    As the itemName binding updates, the input element will temporarily display a tooltip showing the current value, providing immediate visual feedback for debugging.

    hashtag
    Highlight Updates Binding Behavior

    This behavior visually highlights an element by briefly changing its background color whenever the binding updates the element's target property. This visual cue helps quickly identify which parts of the UI are reacting to data changes, particularly useful during development and debugging complex views.

    Usage in Template:

    Whenever the message binding updates the textContent of the div, the div's background will briefly flash light blue for 1 second (1000ms), visually indicating the update. You can customize the highlight color and duration by passing arguments to the binding behavior in the template.

    hashtag
    Built-in Behaviors Reference

    hashtag
    Rate Limiting

    Behavior
    Purpose
    Default
    Parameters
    Signals

    hashtag
    Binding Modes

    Behavior
    Direction
    Use Case

    hashtag
    Event & DOM

    Behavior
    Purpose
    Use Case

    hashtag
    Utility

    Behavior
    Purpose
    Use Case

    hashtag
    Best Practices

    hashtag
    Performance Considerations

    Rate limiting for expensive operations:

    Static content optimization:

    hashtag
    Memory Management

    Proper cleanup in custom behaviors:

    hashtag
    Debugging and Development

    Progressive enhancement approach:

    hashtag
    Common Patterns

    Form handling:

    Search functionality:

    Dynamic content:

    hashtag
    Summary

    Binding behaviors provide powerful ways to customize Aurelia's binding system:

    • Built-in behaviors cover common scenarios like rate limiting, binding modes, and event handling

    • Custom behaviors enable unlimited extensibility for specialized requirements

    • Proper cleanup is essential to prevent memory leaks in custom implementations

    Use binding behaviors to create more efficient, maintainable, and user-friendly applications by controlling exactly how your data flows between view and view-model.

    CustomElement.isType
    CustomElement.keyFrom
    @capture
    @processContent
    ${data | valueConverter:arg & bindingBehavior:arg2}
    .
    'flushInput'
    signal, ensuring the
    formValue
    is updated in the view model right away.
    You need to chain additional behaviors/value converters and prefer not to switch to the .one-time command mid-expression.

    .to-view

    fromView

    View → VM

    Input-only scenarios

    .from-view

    twoWay

    VM ↔ View

    Interactive forms

    .two-way

    Cases where you need the attribute to be visible in browser dev tools

    binding: The binding instance whose behavior you want to alter (implements IBinding interface)

  • [...args]: Any arguments passed to the binding behavior in the template (e.g., & myBehavior:arg1:arg2)

  • unbind(scope, binding): Called when the binding is detached from the DOM. Clean up any changes made in the bind method to restore the binding to its original state and prevent memory leaks.

  • Delay until input stops

    200ms

    delay, signal

    ✅

    View → VM

    Input-only scenarios

    twoWay

    VM ↔ View

    Interactive forms

    Force attribute access

    Custom attributes, SEO

    Performance benefits come from using appropriate behaviors for different use cases
  • Debugging capabilities make development and troubleshooting easier

  • oneTime

    None (static)

    Static content, performance

    N/A

    toView

    VM → View

    throttle

    Limit update frequency

    200ms

    delay, signal

    ✅

    oneTime

    None

    Static content, performance

    toView

    VM → View

    Display-only data

    self

    Filter event source

    Prevent event bubbling

    updateTrigger

    Custom DOM events

    Control when updates occur

    signal

    Manual refresh

    Dynamic content, translations

    Display-only data

    debounce

    fromView

    attr

    <!-- 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>

    Slotted content

    Learn how to project content into custom elements using native slots and au-slot, and how to observe and react to slot changes.

    Aurelia provides two ways to project content into custom elements:

    • <slot> - Native Web Components slot that requires Shadow DOM

    • <au-slot> - Aurelia's slot implementation that works without Shadow DOM

    This guide focuses on slot usage, observation, and advanced patterns. For detailed information about configuring Shadow DOM, styling, and constraints, see the .

    hashtag
    Native Slots (<slot>)

    The <slot> element is the browser-native way to project content into Shadow DOM components.

    circle-exclamation

    Native <slot> elements require Shadow DOM. Attempting to use <slot> without enabling Shadow DOM will throw a compilation error: Template compilation error: detected a usage of "<slot>" element without specifying shadow DOM options.

    For content projection without Shadow DOM, use instead.

    hashtag
    Enabling Shadow DOM for Slots

    Before using <slot>, enable Shadow DOM on your component:

    Because we named our component au-modal we will then use it like this:

    Notice how we add slot="content" to the projected node? This tells Aurelia to send that markup to the <slot name="content"> target. When you target the default slot you omit the slot attribute entirely. Custom elements can have multiple slots, so how do we tell Aurelia where to project our content?

    hashtag
    Named slots

    A named slot is no different to a conventional slot. The only difference is the slot has a name we can reference. A slot without a name gets the name default by default.

    Now, to use our element with a named slot, you can do this:

    hashtag
    Fallback content

    A slot can display default content when nothing is explicitly projected into it. Fallback content works for default and named slot elements.

    hashtag
    Listening to projection change

    hashtag
    At the projection target (<slot> element), with the

    The <slot> element comes with an event based way to listen to its changes. This can be done via listening to slotchange even on the <slot> element, like the following example:

    hashtag
    At the projection source (custom element host), with the @children decorator

    In case where it's not desirable to go to listen to projection change at the targets (<slot> elements), it's also possible to listen to projection at the source with @children decorator. Decorating a property on a custom element class with @children decorator will setup mutation observer to notify of any changes, like the following example:

    After the initial rendering, myDetails.divs will be an array of 1 <div> element, and any future addition of any <div> elements to the <my-details> element will update the divs property on myDetails instance, with corresponding array.

    Additionally, the @children decorator will also call a callback as a reactive change handler. The name of the callback, if omitted in the decorator, will be derived based on the property being decorated, example: divs -> divsChanged

    hashtag
    @children decorator usage

    Usage
    Meaning
    circle-info

    Note: the @children decorator wont update if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves.

    hashtag
    Retrieving component view models

    When using @children to target projected element components, it's often desirable to get the underlying component instances rather than the host elements of those. The @children decorator by default automatically retrieves those instances, like the following examples:

    As items property is decorated with @children('my-item'), its values is always a list of MyItem instances instead of <my-item> elements. You can alter this behavior by specifying a map option, like the following example:

    In the above example, we give map option a function to decide that we want to take the host element instead of the component instance.

    hashtag
    Au-slot

    Aurelia provides another way of content projection with au-slot. This is similar to the native slot when working with content projection. However, it does not use Shadow DOM. au-slot is useful where you want externally defined styles to penetrate the component boundary, facilitating easy styling of components.

    Suppose you create your own set of custom elements solely used in your application. In that case, you might want to avoid the native slots in the custom elements, as it might be difficult to style them from your application.

    However, if you still want slot-like behavior, then you can use au-slot, as that makes styling those custom elements/components easier. Instead of using shadow DOM, the resulting view is composed purely by the Aurelia compilation pipeline.

    There are other aspects of au-slot as well which will be explored in this section with examples.

    circle-info

    An obvious question might be, "Why not simply 'turn off' shadow DOM, and use the slot itself"? We feel that goes opposite to Aurelia's promise of keeping things as close to native behavior as possible. Moreover, using a different name like au-slot makes it clear that the native slot is not used in this case. However, still brings slotting behavior to use.

    circle-info

    If you have used the replaceable and replace part before or with Aurelia1, it is replaced with au-slot.

    hashtag
    Basic templating usage

    Like slot, a "projection target"/"slot" can be defined using a <au-slot> element, and a projection to that slot can be provided using a [au-slot] attribute. Consider the following example.

    In the example above, the my-element custom element defines two slots: one default and one named. The slots can optionally have fallback content; i.e. when no projection is provided for the slot, the fallback content will be displayed. Projecting to a slot is, therefore, also optional. However, when a projection is provided for a slot, that overrides the fallback content of that slot.

    Similar to native shadow DOM and <slot/>/[slot] pair, [au-slot] attribute is not mandatory if you are targeting the default slot. All content without explicit [au-slot] is treated as targeting the default slot. Having no [au-slot] is also equal to having explicit au-slot on the content:

    Another important point to note is that the usage of [au-slot] attribute is supported only on the direct children elements of a custom element. This means that the following examples do not work.

    hashtag
    Using [au-slot] with template controllers

    You can combine [au-slot] with Aurelia template controllers such as if.bind, repeat.for, or your own custom controllers. During compilation Aurelia moves the slotted content into projection templates before it runs those controllers, so the control-flow logic still executes exactly where you expect it to.

    The compiler handles both if.bind and repeat.for on the slotted projections, so you do not need to wrap them inside extra <template> tags unless you prefer that style.

    Inject the projected slot information

    It is possible to inject an instance of IAuSlotsInfo in a element component view model. This provides information related to the slots inside a custom element. The information includes only the slot names for which content has been projected. Let's consider the following example.

    The following would be logged to the console for the instances of my-element.

    hashtag
    Binding scope

    It is also possible to use data-binding, interpolation etc., while projecting. While doing so, the scope accessing rule can be described by the following thumb rule:

    1. When the projection is provided, the scope of the custom element providing the projection is used.

    2. When the projection is not provided, the scope of the inner custom element is used.

    3. The outer custom element can still access the inner scope using the $host keyword while projecting.

    hashtag
    Examples

    To further explain how these rules apply, these rules are explained with the following examples.

    hashtag
    Projection uses the outer scope by defaultthe

    Let's consider the following example with interpolation.

    Although the my-element has a message property, but as my-app projects to s1 slot, scope of my-app is used to evaluate the interpolation expression. Similar behavior can also be observed when binding properties of custom elements, as shown in the following example.

    hashtag
    Fallback uses the inner scope by default

    Let's consider the following example with interpolation. This is the same example as before, but this time without projection.

    Note that in the absence of projection, the fallback content uses the scope of my-element. For completeness, the following example shows that it also holds while binding values to the @bindables in custom elements.

    hashtag
    Access the inner scope with $host

    The outer custom element can access the inner custom element's scope using the $host keyword, as shown in the following example.

    Note that using the $host.message expression, MyApp can access the MyElement#message. The following example demonstrates the same behavior for binding values to custom elements.

    Let's consider another example of $host which highlights the communication between the inside and outside of a custom element that employs <au-slot>

    In the example above, we replace the 'content' template of the grid, defined in my-element, from my-app. While doing so, we can grab the scope of the <au-slot name="content" /> and use the properties made available by the binding expose.bind="{ person, $even, $odd, $index }", and use those in the projection template.

    Note that $host allows us to access whatever the <au-slot/> element exposes, and this value can be changed to enable powerful scenarios. Without the $host it might not have been easy to provide a template for the repeater from the outside.

    circle-info

    expose.bind is reactive. Updating the bound object (for example, when an inner input changes) immediately updates the $host object that all projected templates see.

    circle-info

    The last example is also interesting from another aspect. It shows that many parts of the grid can be replaced with projection while working with a grid. This includes the header of the grid (au-slot="header"), the template column of the grid (au-slot="content"), or even the whole grid itself (au-slot="grid").

    circle-exclamation

    The $host keyword can only be used in the context of projection. Using it in any other context is not supported and will throw errors with high probability.

    hashtag
    Multiple projections for a single slot

    It is possible to provide multiple projections to a single slot.

    This is useful for many cases. One evident example would a 'tabs' custom element.

    This helps keep things closer that belong together. For example, keeping the tab-header and tab-content next to each other provides better readability and understanding of the code to the developer. On other hand, it still places the projected contents in the right slot.

    hashtag
    Duplicate slots

    Having more than one <au-slot> with the same name is also supported. This lets us project the same content to multiple slots declaratively, as can be seen in the following example.

    Note that projection for the name is provided once, but it gets duplicated in 2 slots. You can also see this example in action .

    hashtag
    Listening to <au-slot> change

    Similar like the standard <slot> element allows the ability to listen to changes in the content projected, <au-slot> also provides the capability to listen & react to changes.

    hashtag
    With @slotted decorator

    One way to subscribe to au-slot changes is via the @slotted decorator, like the following example:

    After rendering, the MySummaryElement instance will have paragraphs value as an array of 2 <p> element as seen in the app.html.

    The @slotted decorator will invoke change handler upon initial rendering, and whenever there's a mutation after wards, while the owning custom element is still active. By default, the callback will be selected based on the name of the decorated property. For example: paragraphs -> paragraphsChanged, like the following example:

    ### Change handler callback reminders - The change handler will be called upon the initial rendering, and after every mutation afterwards while the custom element is still active {% %}

    hashtag
    @slotted usage

    The @slotted decorator can be used in multiple forms:

    Usage
    Meaning

    Note: the `@slotted` decorator won't be notified if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves. {% %}

    hashtag
    With slotchange binding

    The standard <slot> element dispatches slotchange events for application to react to changes in the projection. This behavior is also supported with <au-slot>. The different are with <slot>, it's an event while for <au-slot>, it's a callback as there's no host to dispatch an event, for <au-slot> is a containerless element.

    The callback will be passed 2 parameters:

    name
    type
    description

    An example of using slotchange behavior may look like the following:

    circle-info

    hashtag
    slotchange callback reminders

    • The callback will not be called upon the initial rendering, it's only called when there's a mutation after the initial rendering.

    Observe mutation, and select only my-child elements, get the component instance if available and fallback to the element itself

    Observe projection on the default slot, and select all nodes, including text

    @slotted('*')

    Observe projection on the default slot, and select all elements

    @slotted('div', '*')

    Observe projection on all slots, and select only div elements

    @slotted('*', '*')

    Observe projection on all slots, and select all elements

    @slotted({ query: 'div' })

    Observe projection on the default slot, and select only div elements

    @slotted({ slotName: 'footer' })

    Observe projection on footer slot, and select all elements

    @slotted({ callback: 'nodeChanged' })

    Observe projection on default slot, and select all elements, and call nodeChanged method on projection change

  • The callback pass to slotchange of <au-slot> will be call with an undefined this, so you should either give it a lambda expression, or a function like the example above.

  • The nodes passed to the 2nd parameter of the slotchange callback will always be the latest list of nodes.

  • the slotchange callback doesn't fire if the children of a slotted node change — only if you change (e.g. add or delete) the actual nodes themselves.

  • slotchange is independent of @slotted; you can bind it even if you do not observe the slot contents elsewhere.

  • @children() prop

    Use default options, observe mutation, and select all elements

    @children('div') prop

    Observe mutation, and select only div elements

    @children({ query: 'my-child' })

    Observe mutation, and select only my-child elements, get the component instance if available and fallback to the element itself

    @slotted() prop

    Use default options, observe projection on the default slot, and select all elements

    @slotted('div') prop

    Observe projection on the default slot, and select only div elements

    @slotted('div', 'footer') prop

    Observe projection on the footer slot and select only div elements

    name

    string

    the name of the slot calling the change callback

    nodes

    Node[]

    the list of the latest nodes that belongs to the slot calling the change callback

    Shadow DOM guide
    <au-slot>
    slotchange eventarrow-up-right
    herearrow-up-right
    import { customElement, useShadowDOM } from 'aurelia';
    
    @customElement('au-modal')
    @useShadowDOM()
    export class AuModal {}
    <div class="modal">
        <div class="modal-inner">
            <slot></slot>
        </div>
    </div>

    @children({ query: 'my-child', map: (node, viewModel) => viewModel ?? node })

    @slotted('$all')

    <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">&times;</span></button>
        <div class="modal-inner">
            <slot>This is the default content shown if the user does not supply anything.</slot>
        </div>
    </div>
    my-app.html
    <slot slotchange.trigger="handleSlotChange($event.target.assignedNodes())"></slot>
    my-app.ts overflow=
    class MyApp {
      handleSlotChange(nodes: Node[]) {
        console.log('new nodes are:', nodes);
      }
    }
    my-details.ts
    import { children } from 'aurelia';
    
    export class MyDetails {
      @children('div') divs: HTMLElement[];
    }
    my-app.html
    <my-details>
      <div>@children decorator is a good way to listen to node changes without having to deal with boilerplate yourself</div>
    </my-details>
    my-item.ts
    export class MyItem {
      ...
    }
    my-list.ts
    import { children } from 'aurelia';
    import { MyItem } from './my-item';
    
    export class MyList {
      @children('my-item') items: MyItem[];
    }
    my-app.html
    <import from="my-list">
    
    <my-list>
      <my-item repeat.for="option of options" value.bind="option.value"></my-item>
    </my-list>
    my-list.ts
    import { children } from 'aurelia';
    import { MyItem } from './my-item';
    
    export class MyList {
      @children({
        query: 'my-item',
        map: (node) => node
      })
      items: HTMLElement[];
    }
    my-element.html
    static content
    <au-slot>fallback content for default slot.</au-slot>
    <au-slot name="s1">fallback content for s1 slot.</au-slot>
    my-app.html
    <!-- 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>
    -->
    my-app.html
    <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>
    -->
    my-app.html
    <!-- Do NOT work. -->
    
    <div au-slot></div>
    
    <template><div au-slot></div></template>
    
    <my-element>
      <div>
        <div au-slot></div>
      </div>
    </my-element>
    my-card.html
    <au-slot name="actions"></au-slot>
    <ul>
      <au-slot name="items"></au-slot>
    </ul>
    app.html
    <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,
      ) { }
    }
    my-element.html
    <au-slot name="s1">s1</au-slot>
    <au-slot name="s2">s2</au-slot>
    my-app.html
    <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>
    -->
    my-element.html
    <au-slot name="header"></au-slot>
    <au-slot name="content"></au-slot>
    my-app.html
    <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>
    person-card.html
    <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>
    my-app.html
    <person-card>
      <span au-slot="name"> John Doe </span>
      <span au-slot="role"> Role1 </span>
      <span au-slot="details"> Lorem ipsum </span>
    </person-card>
    app.html
    <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.html
    <p>Heading text</p>
    <div>
      <au-slot></au-slot>
    </div>
    my-summary.ts
    import { slotted } from 'aurelia';
    
    export class MySummaryElement {
      @slotted('p') paragraphs // assert paragraphs.length === 2
    }
    my-summary.ts
    import { slotted } from 'aurelia';
    
    export class MySummaryElement {
      @slotted('p') paragraphs // assert paragraphs.length === 2
    
      paragraphsChanged(ps: HTMLParagraphElement[]) {
        // do things
      }
    }
    my-summary.html
    <p>Heading text</p>
    <div>
      <au-slot></au-slot>
    </div>
    app.html
    <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>
    app.html
    <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>
    my-summary.html
    <p>Heading text</p>
    <div>
      <au-slot slotchange.bind="onContentChange"></au-slot>
      <au-slot slotchange.bind="(name, nodes) => doSomething(name, nodes)"></au-slot>
    </div>
    my-summary.ts
    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);
      }
    }

    Lifecycle Visual Diagrams

    Visual explanations of Aurelia 2's component lifecycle with parent-child timing.

    hashtag
    Table of Contents

    1. Activation Sequence (Parent-Child)


    hashtag
    1. Activation Sequence (Parent-Child)

    How lifecycle hooks execute when activating a parent with children:


    hashtag
    2. Deactivation Sequence (Parent-Child)

    How lifecycle hooks execute when deactivating:


    hashtag
    3. Stack-Based Coordination

    How Aurelia ensures correct timing using activation/deactivation stacks:


    hashtag
    4. Async Lifecycle Behavior

    How async hooks affect timing:


    hashtag
    5. Common Pitfalls

    Real-world mistakes and how to avoid them:


    hashtag
    Summary

    Key Takeaways:

    1. Activation is top-down until attached (which is bottom-up)

    2. Deactivation is bottom-up throughout

    3. binding() blocks children, attaching() doesn't

    For more details, see the main documentation.

    Stacks coordinate timing between parent and children
  • Always clean up in the opposite hook (attached ↔ detaching)

  • Use attached() for DOM work, not binding()

  • Deactivation Sequence (Parent-Child)
    Stack-Based Coordination
    Async Lifecycle Behavior
    Common Pitfalls
    Component Lifecycles
    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 docs
    SCENARIO: 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, attached

    Value converters (pipes)

    Master Aurelia's value converters for powerful data transformation. Learn formatting, localization, custom converters, performance optimization, and real-world patterns.

    Value converters are a powerful feature in Aurelia 2 that transform data as it flows between your view model and view. They enable clean separation of concerns by moving data formatting logic out of your view models while keeping templates readable and maintainable.

    hashtag
    Overview

    Value converters excel at:

    • Data formatting - dates, numbers, currencies, text transformations

    • Localization - dynamic content based on user locale

    • Display logic - conditional formatting without cluttering view models

    • Two-way transformations - handling both display and input conversion

    • Reactive updates - automatic re-evaluation on global state changes

    • Performance optimization - caching expensive transformations

    hashtag
    Key Advantages

    • Pure functions - predictable, testable transformations

    • Reusable - use the same converter across multiple components

    • Composable - chain multiple converters for complex transformations

    hashtag
    Data Flow

    Converters work in two directions:

    • toView: Prepares model data for display.

    • fromView: Adjusts view data before updating the model (useful with two-way binding).

    Both methods receive the primary value as the first argument, with any extra arguments used as configuration.

    hashtag
    Example Methods

    hashtag
    Basic Usage

    hashtag
    Template Syntax

    Use the pipe symbol (|) to apply a converter in templates:

    hashtag
    Simple Converter Example

    Usage in template:

    hashtag
    Parameter Passing

    Converters accept parameters using colons (:) for configuration:

    hashtag
    Static Parameters

    hashtag
    Bound Parameters

    hashtag
    Object Parameters

    hashtag
    Chaining Converters

    Chain multiple converters for complex transformations:

    Chain execution order: Left to right, where each converter receives the output of the previous one.

    hashtag
    Advanced Template Patterns

    hashtag
    Conditional Formatting

    hashtag
    Dynamic Parameter Selection

    hashtag
    Nested Object Access

    hashtag
    Receiving the Caller Context

    By default, value converters receive only the value to transform and any configuration parameters. In some advanced scenarios, you may need to know more about the binding or calling context that invoked the converter—for example, to adjust the transformation based on the host element, attributes, or other binding-specific state.

    Aurelia 2 provides an opt-in mechanism to receive the binding instance itself as an additional parameter. To enable this feature:

    1. Add withContext: true to your value converter class:

    Then use your converter in templates as usual:

    At runtime, Aurelia will detect withContext: true in the value converter and pass the binding instance as the second parameter. Depending on how the converter is used:

    • Property Binding (foo.bind or attr.bind): the caller is a PropertyBinding instance

    • Interpolation (${ } with converters): the caller is an InterpolationPartBinding instance

    hashtag
    Common Use Cases

    • Logging or debugging which binding invoked the converter

    • Applying different formatting based on binding context

    • Accessing binding metadata or context not available through standard converter parameters

    Use this feature sparingly, only when you truly need insights into the calling context. For most formatting scenarios, simple converter parameters and camelCase converter names are sufficient.

    hashtag
    Accessing the View Model and Binding Context

    Once withContext: true is enabled, your converter receives a caller parameter with direct access to the view model and binding information:

    hashtag
    Caller Context Properties

    • caller.source: The view model instance of the component where the converter is used

      • This is the actual component class instance with all its properties and methods

      • Allows converters to access component state, computed properties, and methods

    hashtag
    Real-World Example: User Permission Converter

    Usage in template:

    hashtag
    Registration Patterns

    Aurelia 2 provides flexible registration patterns for different use cases and architectural preferences.

    hashtag
    1. Decorator Registration (Recommended)

    The most common and straightforward approach:

    hashtag
    2. Configuration Object Registration

    For advanced options including aliases:

    Usage with aliases:

    hashtag
    3. Static Definition

    Using the static $au property (alternative registration approach):

    hashtag
    4. Manual Registration

    For dynamic or runtime registration scenarios:

    hashtag
    5. Local vs Global Registration

    hashtag
    Global Registration (Application-wide)

    hashtag
    Local Registration (Component-specific)

    hashtag
    Scoped Registration (Feature Module)

    hashtag
    6. Conditional Registration

    Register converters based on environment or feature flags:

    hashtag
    Best Practices for Registration

    1. Use decorators for most cases - Simple and straightforward

    2. Group related converters - Organize by feature or domain

    3. Consider lazy loading - Register heavy converters only when needed

    hashtag
    Creating Custom Value Converters

    Custom value converters are classes that implement transformation logic. They provide a clean way to handle data formatting throughout your application.

    hashtag
    Basic Structure

    hashtag
    TypeScript Best Practices

    hashtag
    Strong Typing

    hashtag
    Generic Converters

    hashtag
    Bidirectional Converters (Two-Way Binding)

    Perfect for form inputs that need both display formatting and input parsing:

    hashtag
    Phone Number Formatter

    Usage with two-way binding:

    hashtag
    Credit Card Formatter

    hashtag
    Error Handling and Validation

    hashtag
    Performance Optimization

    hashtag
    Memoized Converter

    hashtag
    Utility Converters

    hashtag
    Null-Safe Converter

    hashtag
    Debug Converter

    Usage:

    hashtag
    Signals-Based Reactivity

    Value converters can automatically re-evaluate when specific signals are dispatched, perfect for locale changes, theme updates, or global state changes.

    To trigger re-evaluation from anywhere in your app:

    Now all localeDate converters automatically update when the locale changes, without needing to manually refresh bindings.

    hashtag
    Built-in Signal-Aware Converters

    Aurelia 2 includes several built-in converters that leverage signals:

    hashtag
    Built-in Value Converters

    Aurelia 2 includes several built-in converters ready for use:

    hashtag
    Sanitize Converter

    Aurelia 2 includes a sanitize converter, but it requires you to provide your own sanitizer implementation:

    Then you can use the sanitize converter:

    Note: The built-in sanitize converter throws an error by default. You must provide your own ISanitizer implementation for it to work.

    hashtag
    I18n Converters (when @aurelia/i18n is installed)

    hashtag
    Advanced Configuration Options

    hashtag
    Date Formatter Example

    This converter formats dates based on locale:

    Import it in your view:

    Usage examples:

    View this in action on .

    hashtag
    Real-World Converter Examples

    hashtag
    File Size Converter

    Convert bytes to human-readable file sizes:

    hashtag
    Relative Time Converter

    Display time relative to now (e.g., "2 hours ago"):

    hashtag
    Truncate with Tooltip Converter

    Truncate text with full text available on hover:

    hashtag
    Markdown to HTML Converter

    Convert markdown text to HTML (using marked library):

    hashtag
    Search Highlight Converter

    Highlight search terms in text:

    hashtag
    Sort Array Converter

    Sort arrays by property or custom function:

    hashtag
    Color Converter

    Convert between color formats:

    hashtag
    Performance Optimization

    hashtag
    Caching Strategies

    Implement intelligent caching for expensive operations:

    hashtag
    Lazy Evaluation

    Defer expensive operations until actually needed:

    hashtag
    Memory Management

    Prevent memory leaks in converters:

    hashtag
    Benchmark and Profile

    Use performance measurement for optimization:

    hashtag
    Best Practices

    hashtag
    1. Design Principles

    Single Responsibility

    Pure Functions

    hashtag
    2. TypeScript Integration

    Strong Typing

    Generic Constraints

    hashtag
    3. Error Handling

    Graceful Degradation

    hashtag
    4. Testing Strategies

    Unit Testing

    hashtag
    Troubleshooting Common Issues

    hashtag
    Issue: Converter Not Found

    Problem: Template shows error "No ValueConverter named 'myConverter' was found"

    Solutions:

    1. Import the converter:

    2. Check decorator name:

    3. Global registration:

    hashtag
    Issue: Performance Problems

    Problem: Page becomes slow with converters in loops

    Solutions:

    1. Implement caching:

    2. Use signals for global updates:

    3. Optimize template usage:

    hashtag
    Issue: Context Access Not Working

    Problem: caller parameter is undefined in toView

    Solutions:

    1. Enable context access:

    2. Correct parameter order:

    hashtag
    Issue: Signals Not Triggering

    Problem: Converter doesn't update when signal is dispatched

    Solutions:

    1. Declare signals array:

    2. Dispatch signals correctly:

    hashtag
    Built-in Converters Reference

    hashtag
    Core Converters

    Converter
    Purpose
    Package
    Parameters

    hashtag
    I18n Converters (when @aurelia/i18n is installed)

    Converter
    Purpose
    Parameters
    Example

    hashtag
    Usage Examples

    hashtag
    Summary

    Value converters in Aurelia 2 provide a powerful, flexible system for data transformation:

    • Bidirectional support - Handle both display formatting and input parsing

    • Signal-based reactivity - Automatic updates on global state changes

    • Context awareness - Access binding context when needed

    Use value converters to keep your templates clean and maintainable while providing rich data formatting capabilities throughout your application.

    Bindable properties

    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.

    hashtag
    Bindable properties

    When creating components, sometimes you will want the ability for data to be passed into them instead of their host elements. The @bindable decorator allows you to specify one or more bindable properties for a component.

    The @bindable attribute also can be used with custom attributes as well as custom elements. The decorator denotes bindable properties on components on the view model of a component.

    This will allow our component to be passed in values. Our specified bindable property here is called loading and can be used like this:

    In the example above, we are binding the boolean literal true to the loading property.

    Instead of literal, you can also bind another property (loadingVal in the following example) to the loading property.

    As seen in the following example, you can also bind values without the loading.bind part.

    circle-exclamation

    Aurelia treats attribute values as strings. This means when working with primitives such as booleans or numbers, they won't come through in that way and need to be coerced into their primitive type using a or specifying the bindable type explicitly using .

    The @bindable decorator signals to Aurelia that a property is bindable in our custom element. Let's create a custom element where we define two bindable properties.

    You can then use the component in this way,`<name-component first-name="John" last-name="Smith"></name-component>

    hashtag
    Calling a change function when bindable is modified

    By default, Aurelia will call a change callback (if it exists) which takes the bindable property name followed by Changed added to the end. For example, firstNameChanged(newVal, previousVal) would fire every time the firstName bindable property is changed.

    circle-exclamation

    Due to the way the Aurelia binding system works, change callbacks will not be fired upon initial component initialization. If you worked with Aurelia 1, this behavior differs from what you might expect.

    If you would like to call your change handler functions when the component is initially bound (like v1), you can achieve this the following way:

    If you have multiple bindable properties like firstName/lastName in the above example, and want to use a single callback to react to those changes, you can use propertyChanged callback. propertyChanged callback will be called immediately after the targeted change callback. The parameters of this callback will be key/newValue/oldValue, similar like the following example:

    In the above example, even though propertyChanged can be used for multiple properties (like firstName and lastName), it's only called individually for each of those properties. If you wish to act on a group of changes, like both firstName and lastName at once in the above example, propertiesChanged callback can used instead, like the following example:

    For the order of callbacks when there are multiple callbacks involved, refer to the following example: If we have a component class that looks like this:

    When we do

    the console logs will look like the following:

    Note: The individual change callback (propChanged) and propertyChanged execute immediately when the property is set, while propertiesChanged is deferred and executes asynchronously in the next tick.

    hashtag
    Configuring bindable properties

    Like almost everything in Aurelia, you can configure how bindable properties work.

    hashtag
    Change the binding mode using mode

    You can specify the binding mode using the mode property and passing in a valid BindingMode to it; @bindable({ mode: BindingMode.twoWay}) - this determines which way changes flow in your binding. By default, this will be BindingMode.oneWay

    circle-info

    Please consult the documentation below to learn how to change the binding modes. By default, the binding mode for bindable properties will be one-way

    hashtag
    Change the name of the change callback

    You can change the name of the callback that is fired when a change is made @bindable({ callback: 'propChanged' })

    hashtag
    Change the attribute name using attribute

    By default, Aurelia converts camelCase property names to kebab-case attribute names (e.g., firstName becomes first-name). You can override this with the attribute option:

    This allows the component to be used with a custom attribute name:

    hashtag
    Set the default property for custom attributes

    When creating custom attributes, you can specify which property receives the value when the attribute is used without explicitly naming a property. Use the defaultProperty option on the @customAttribute decorator:

    This allows a simpler usage syntax:

    circle-info

    If defaultProperty is not specified, it defaults to 'value'. This allows custom attributes without any bindables defined to automatically receive values through a value property.

    Bindable properties support many different binding modes determining the direction the data is bound in and how it is bound.

    hashtag
    One way binding

    By default, bindable properties will be one-way binding (also known as toView). This means values flow into your component but not back out of it (hence the name, one way).

    circle-info

    Bindable properties without a mode explicitly set will be toView (one-way) by default. You can also explicitly specify the binding mode.

    hashtag
    Two-way binding

    Unlike the default, the two-way binding mode allows data to flow in both directions. If the value is changed with your component, it flows back out.

    hashtag
    One-time binding

    The one-time binding mode binds a value once and never updates it again, even if the source value changes. This is useful for static values that won't change after initial binding.

    hashtag
    From-view binding

    The from-view binding mode allows data to flow from the view (target) to the view model (source), but not the other way. This is the opposite of toView.

    hashtag
    Working with two-way binding

    Much like most facets of binding in Aurelia, two-way binding is intuitive. Instead of .bind you use .two-way if you need to be explicit, but in most instances, you will specify the type of binding relationship a bindable property is using with @bindable instead.

    Explicit two-way binding looks like this:

    The myVal variable will get a new value whenever the text input is updated. Similarly, if myVal were updated from within the view model, the input would get the updated value.

    circle-info

    When using .bind for input/form control values such as text inputs, select dropdowns and other form elements. Aurelia will automatically create a two-way binding relationship. So, the above example using a text input can be rewritten to be value.bind="myVal" , and it would still be a two-way binding.

    hashtag
    Bindable setter

    In some cases, you want to make an impact on the value that is binding. For such a scenario, you can use the possibility of new set.

    Suppose you have a carousel component in which you want to enable navigator feature for it.

    In version two, you can easily implement such a capability with the set feature.

    Define your property like this:

    For set part, we need functionality to check the input. If the value is one of the following, we want to return true, otherwise, we return the false value.

    • '': No input for a standalone navigator property.

    • true: When the navigator property set to true.

    So our function will be like this

    Now, we should set truthyDetector function as follows:

    Although, there is another way to write the functionality too:

    You can simply use any of the above four methods to enable/disable your feature. As you can see, set can be used to transform the values being bound into your bindable property and offer more predictable results when dealing with primitives like booleans and numbers.

    hashtag
    Bindable & getter/setter

    By default you'll work with bindable fields most of the time, like the examples above. But there are cases where it makes sense to expose a bindable getter (or getter/setter pair) so you can compute the value on the fly.

    For example, a component card nav that allow parent component to query its active status. With bindable on field, it would be written like this:

    Note that because active value needs to computed from other variables, we have to "actively" call setActive. It's not a big deal, but sometimes not desirable.

    For cases like this, we can turn active into a getter, and decorate it with bindable, like the following:

    Simpler, since the value of active is computed, and observed based on the properties/values accessed inside the getter.

    hashtag
    Bindable coercion

    The bindable setter section shows how to adapt the value is bound to a @bindable property. One common usage of the setter is to coerce the values that are bound from the view. Consider the following example.

    Without any setter for the @bindable num we will end up with the string '42' as the value for num in MyEl. You can write a setter to coerce the value. However, it is a bit annoying to write setters for every @bindable.

    hashtag
    Automatic type coercion

    To address this issue, Aurelia 2 supports type coercion. To maintain backward compatibility, automatic type coercion is disabled by default and must be enabled explicitly.

    There are two relevant configuration options.

    enableCoercion

    The default value is false; that is Aurelia 2 does not coerce the types of the @bindable by default. It can be set to true to enable the automatic type-coercion.

    coerceNullish

    The default value is false; that is Aurelia2 does not coerce the null and undefined values. It can be set to true to coerce the null and undefined values as well. This property can be thought of as the global counterpart of the nullable property in the bindable definition (see section).

    Additionally, depending on whether you are using TypeScript or JavaScript for your app, there can be several ways to use automatic type coercion.

    hashtag
    Specify type in @bindable

    You need to specify the explicit type in the @bindable definition.

    circle-info

    The rest of the document is based on TypeScript examples. However, we trust that you can transfer that knowledge to your JavaScript codebase if necessary.

    hashtag
    Coercing primitive types

    Currently, coercing four primitive types are supported out of the box. These are number, string, boolean, and bigint. The coercion functions for these types are respectively Number(value), String(value), Boolean(value), and BigInt(value).

    circle-exclamation

    Be mindful when dealing with bigint as the BigInt(value) will throw if the value cannot be converted to bigint; for example null, undefined, or non-numeric string literal.

    hashtag
    Coercing to instances of classes

    It is also possible to coerce values into instances of classes. There are two ways how that can be done.

    hashtag
    Using a static coerce method

    You can define a static method named coerce in the class used as a @bindable type. This method will be called by Aurelia2 automatically to coerce the bound value.

    This is shown in the following example with the Person class.

    According to the Person#coercer implementation, for the example above MyEl#person will be assigned an instance of Person that is equivalent to new Person('john', null).

    hashtag
    Using the @coercer decorator

    Aurelia2 also offers a @coercer decorator to declare a static method in the class as the coercer. The previous example can be rewritten as follows using the @coercer decorator.

    With the @coercer decorator, you are free to name the static method as you like.

    hashtag
    Coercing nullable values

    To maintain backward compatibility, Aurelia2 does not attempt to coerce null and undefined values. We believe that this default choice should avoid unnecessary surprises and code breaks when migrating to newer versions of Aurelia.

    However, you can explicitly mark a @bindable to be not nullable.

    When nullable is set to false, Aurelia2 will try to coerce the null and undefined values.

    hashtag
    set and auto-coercion

    It is important to note that an explicit set (see ) function is always prioritized over the type. In fact, the auto-coercion is the fallback for the set function. Hence whenever set is defined, the auto-coercion becomes non-operational.

    However, this gives you an opportunity to:

    • Override any of the default primitive type coercing behavior, or

    • Disable coercion selectively for a few selective @bindable by using a noop function for set.

    circle-info

    Aurelia2 already exposes a noop function saving your effort to write such boring functions.

    hashtag
    Union types

    When using TypeScript, usages of union types are not rare. However, using union types for @bindable will deactivate the auto-coercion.

    For the example above, the type metadata supplied by TypeScript will be Object disabling the auto-coercion.

    To coerce union types, you can explicitly specify a type.

    However, using a setter would be more straightforward to this end.

    circle-info

    Even though using a noop function for set function is a straightforward choice, Object can also be used for type in the bindable definition to disable the auto-coercion for selective @bindables (that is when the automatic type-coercion is enabled).

    hashtag
    Bindables spreading

    Spreading syntaxes are supported for simpler binding of multiple bindable properties.

    Given the following component:

    with template:

    and its usage template:

    The rendered html will be:

    Here we are using ...$bindables to express that we want to bind all properties in the object { first: 'John', last: 'Doe' } to bindable properties on <name-tag> component. The ...$bindables="..." syntax will only connect properties that are matching with bindable properties on <name-tag>, so even if an object with hundreds of properties are given to a ...$bindables binding, it will still resulted in 2 bindings for first and last.

    ...$bindables also work with any expression, rather than literal object, per the following examples:

    hashtag
    Shorthand syntax

    Sometimes when the expression of the spread binding is simple, we can simplify the binding even further. Default templating syntax of Aurelia supports a shorter version of the above examples:

    circle-exclamation
    • Remember that HTML is case insensitive, so ...firstName actually will be seen as ...firstname, for example

    • Bindables properties will be tried to matched as is, which means a firstName

    hashtag
    Binding orders

    The order of the bindings created will be the same with the order declared in the template. For example, for the NameTag component above, if we have a usage

    Then the value of the first property in NameTag with id=1 will be Jane, and the value of first property in NameTag with id=2 will be John.

    circle-exclamation
    • An exception of this order is when bindables spreading is used together with , ...$attrs will always result in bindings after ...$bindables/$bindables.spread/...expression.

    hashtag
    Observation behavior

    Bindings will be created based on the keys available in the object evaluated from the expression of a spread binding. The following example illustrate the behavior:

    For the NameTag component above:

    The rendered HTML of <name-tag> will be

    When clicking on the button with text Change last name, the rendered html of <name-tag> won't be changed, as the original object given to <name-tag> doesn't contain last, hence it wasn't observed, which ignore our new value set from the button click. If it's desirable to reset the observation, give a new object to the spread binding, like the following example:

    circle-check
    • With the above behavior of non-eager binding, applications can have the opportunity to leave some bindable properties untouched, while with the opposite behavior of always observing all properties on the given object based on the number of bindable properties, missing value (null/undefined) will start flowing in in an unwanted way.

    There are some other behaviors of the spread binding that are worth noting:

    • All bindings created with $bindables.spread or ... syntax will have binding mode equivalent to to-view, binding behavior cannot alter this. Though other binding behavior like throttle/debounce can still work.

    • If the same object is returned from evaluating the expression, the spread binding won't try to rebind its inner bindings. This means mutating and then reassigning won't result in new binding, instead, give the spread binding a new object.

    hashtag
    Attributes Transferring

    Attribute transferring is a way to relay the binding(s) on a custom element to its child element(s).

    As an application grows, the components inside it also grow. Something that starts simple, like the following component

    with the template

    can quickly grow out of hand with a number of needs for configuration: aria, type, min, max, pattern, tooltip, validation etc...

    After a while, the FormInput component above will become more and more like a relayer to transfer the bindings from outside, to the elements inside it. This often results in an increase in the number of @bindable. While this is fine, you end up with components that have a lot of boilerplate.

    And the usage of our component would look like this:

    to be repeated like this inside:

    To juggle all the relevant pieces for such a task isn't difficult, but somewhat tedious. With attribute transferring, which is roughly close to object spreading in JavaScript, the above template should be as simple as:

    , which reads like this: for some bindings on <form-input>, change the targets of those bindings to the <input> element inside it.

    hashtag
    Usage

    To transfer attributes & bindings from a custom element, there are two steps:

    • Set capture to true on a custom element via @customElement decorator:

    Or use the capture decorator from aurelia package if you don't want to declare the customElement decorator and have to specify your name and template values.

    As the name suggests, this is to signal the template compiler that all the bindings & attributes, with some exceptions, should be captured for future usage.

    Spread the captured attributes onto an element

    Using the ellipsis syntax which you might be accustomed to from Javascript, we can spread our attributes onto an element proceeding the magic variable $attrs

    Spread attributes and overriding specific ones

    In case you want to spread all attributes while explicitly overriding individual ones, make sure these come after the spread operator.

    circle-exclamation

    It's recommended that this feature should not be overused in multi-level capturing & transferring. This is often known as prop-drilling in React and could have a bad effect on the overall & long-term maintainability of an application. It's probably healthy to limit the max level of transferring to 2.

    hashtag
    Usage with conventions

    Aurelia conventions enable the setting of capture metadata from the template via <capture> tag, like the following example:

    hashtag
    Attribute filtering

    Sometimes it is desirable to capture only certain attributes on a custom element. Aurelia supports this via a function form of the custom element capture value: a function that takes 1 parameter (the attribute name) and returns a boolean to indicate whether it should be captured.

    Note: When using a capture filter function, you cannot use the standalone @capture decorator. You must specify the filter function within the @customElement decorator's capture property.

    hashtag
    How it works

    What attributes are captured

    Everything except the template controller and custom element bindables are captured.

    A usage example is as follows:

    What is captured:

    • value.bind="extraComment"

    • class="form-control"

    • style="background: var(--theme-purple)"

    What is not captured:

    • if.bind="needsComment" (if is a template controller)

    • label.bind="label" (label is a bindable property)

    How will attributes be applied in ...$attrs

    Attributes that are spread onto an element will be compiled as if it was declared on that element.

    This means .bind command will work as expected when it's transferred from some element onto some element that uses .two-way for .bind.

    It also means that spreading onto a custom element will also work: if a captured attribute targets a bindable property of the applied custom element. An example:

    if value is a bindable property of my-input, the end result will be a binding that connects the message property of the corresponding app.html view model with <my-input> view model value property. The binding mode is also preserved like normal attributes.

    hashtag
    Performance Considerations

    hashtag
    Spread vs Individual Bindings

    When deciding between spread syntax and individual bindings, consider the following performance implications:

    Spread Syntax Benefits:

    • Reduces template verbosity and maintains cleaner code

    • Automatically handles dynamic property sets

    • Eliminates the need for manual bindable declarations for pass-through properties

    Individual Binding Benefits:

    • Slightly more efficient for small, known sets of properties

    • Explicit property access provides better type safety

    • Easier to debug specific binding issues

    hashtag
    Memory and Observation Overhead

    Understanding the observation behavior helps optimize performance:

    Best Practice: Use spread syntax when you need to pass through a focused set of properties, not entire large objects.

    hashtag
    Advanced Patterns

    hashtag
    Complex Expression Patterns

    Spread syntax supports complex expressions and transformations:

    hashtag
    Combining Spread with Other Binding Features

    hashtag
    Error Handling Patterns

    hashtag
    Best Practices

    hashtag
    When to Use Spread Syntax

    ✅ Good Use Cases:

    • Component composition and wrapper components

    • Dynamic forms with varying field sets

    • Passing through configuration objects

    • Creating reusable component libraries

    ❌ Avoid When:

    • You need explicit control over individual bindings

    • Working with large objects with many irrelevant properties

    • Performance is critical and you're binding a small, known set of properties

    • You need different binding modes for different properties

    hashtag
    Maintainability Guidelines

    Recommended Limits:

    • Maximum 2 levels of attribute transferring to avoid prop-drilling

    • Use spread for groups of related properties, not entire application state

    • Document spread behavior in component interfaces

    hashtag
    Type Safety Considerations

    hashtag
    Common Patterns and Anti-Patterns

    hashtag
    ✅ Recommended Patterns

    hashtag
    ❌ Anti-Patterns to Avoid

    hashtag
    Debugging and Troubleshooting

    hashtag
    Common Issues

    1. HTML Case Sensitivity

    2. Property Observation Not Working

    3. Binding Mode Conflicts

    hashtag
    Debugging Tips

    Understanding these patterns and considerations will help you use Aurelia's spread syntax effectively while maintaining good performance and code maintainability.

    Framework integration - seamless integration with Aurelia's binding system
  • TypeScript support - full type safety and intellisense

  • Other Bindings: the caller corresponds to the specific binding type in use

    Always available when converter is used within a component

  • caller.binding: The binding instance that invoked the converter

    • Contains binding-specific information and metadata

    • Useful for debugging or advanced binding manipulation

    • Type varies: PropertyBinding, InterpolationPartBinding, etc.

  • Document aliases - Make alternative names clear to team members
  • Avoid global pollution - Use local registration for component-specific logic

  • `${date

    nf

    Number formatting

    options

    `${price

    rt

    Relative time

    None

    `${timestamp

    Performance optimization - Built-in caching and lazy evaluation support
  • Type safety - Full TypeScript support with strong typing

  • Flexible registration - Multiple registration patterns for different needs

  • Extensibility - Easy to create custom converters for specific requirements

  • sanitize

    HTML sanitization

    @aurelia/runtime-html

    None (requires ISanitizer implementation)

    t

    Translation

    key, options

    `${'hello'

    df

    Date formatting

    StackBlitzarrow-up-right

    options

    "true": When the navigator property set to "true".

    bindable property will match an object
    firstName
    property, but not
    first-name
  • If the expression contains space, it will result into multiple attributes and thus won't work as intended with spread syntax .... For example ...a + b will be actually turned into 3 attributes: ...a, + and b

  • tooltip="Hello, ${tooltip}"

    bindable setter
    bindable coercion
    binding modes
    Coercing nullable values
    bindable setter
    ...$attrs
    import { 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 property
    toView(value: unknown, caller: ICallerContext, ...args: unknown[]): unknown {
      // caller is always second parameter when withContext = true
    }
    readonly signals = ['my-signal']; // Array of signal names
    import { 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>
    loader-component.ts
    import { bindable } from 'aurelia';
    
    export class LoaderComponent {
        @bindable loading = false;
    }
    loader-component.html
    <loader loading.bind="true"></loader>
    loader-component.html
    <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'
    })
    form-input.ts
    export class FormInput {
      @bindable label
    }
    my-app.html
    <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-');
      }
    })

    Visual Diagrams

    Visual explanations of Aurelia 2's router architecture and concepts.

    hashtag
    Table of Contents

    1. Route Matching Pipeline


    hashtag
    1. Route Matching Pipeline

    How the router resolves a URL to components:

    Key Points:

    • Routes are matched top-to-bottom in configuration order

    • First matching route wins

    • Parameters are extracted during matching

    • Constraints ({{regex}}


    hashtag
    2. Navigation Flow

    How different navigation methods work:

    Decision Guide:

    • Use href for simple, static links

    • Use load when you need parameter binding or active state

    • Use IRouter.load() for conditional/programmatic navigation


    hashtag
    3. Lifecycle Hook Execution Order

    Complete sequence when navigating from ComponentA to ComponentB:

    Important Notes:

    1. All hooks can be async (return Promise)

    2. Router waits for each hook to complete before proceeding

    3. Returning false from guard hooks stops navigation


    hashtag
    4. Component vs Router Hooks

    Two ways to implement lifecycle logic:

    Decision Guide:

    • Router hooks for: Authentication, authorization, logging, analytics

    • Component hooks for: Data fetching, validation, component state

    • Both when you need layered checks (global + local)

    |


    hashtag
    5. Viewport Hierarchy

    How viewports nest and relate to each other:

    Key Concepts:

    • Default viewport: <au-viewport></au-viewport> (no name)

    • Named viewport: <au-viewport name="aside"></au-viewport>

    • Target viewport: Use @viewportName


    hashtag
    6. History Strategy

    How router interacts with browser history:

    Decision Guide:

    • push: Normal navigation, want history

    • replace: Redirects, corrections, interim states

    • none: Modals, overlays, no history needed


    hashtag
    7. Transition Plans

    What happens when navigating to the same component with different parameters:

    Rule of Thumb:

    • Default (replace): Safe, always works

    • invoke-lifecycles: Optimize when parameters drive content, not fundamentally different pages


    hashtag
    8. Route Parameter Flow

    How parameters flow from URL to component:

    Key Points:

    • All parameters are strings

    • Path params come from URL segments

    • Query params come from ?key=value

    |


    hashtag
    Summary

    These diagrams cover the core architectural concepts of Aurelia 2's router:

    1. Route Matching - How URLs become components

    2. Navigation - Three ways to navigate and their differences

    3. Lifecycle - Complete hook execution sequence

    For more details, see the complete .

    Custom attributes

    Learn how to build and enhance Aurelia 2 custom attributes, including advanced configuration, binding strategies, and accessing the host element.

    Custom attributes in Aurelia empower you to extend and decorate standard HTML elements by embedding custom behavior and presentation logic. They allow you to wrap or integrate existing HTML plugins and libraries, or simply enhance your UI components with additional dynamic functionality. This guide provides a comprehensive overview—from basic usage to advanced techniques—to help you leverage custom attributes effectively in your Aurelia 2 projects.


    hashtag
    Table of Contents

    ) are validated
  • Hierarchical routes build a route tree

  • Router hooks run before component hooks
  • unloading and loading happen in parallel for performance

  • in navigation
  • Hierarchical: Nested components each have their own viewport

  • Sibling: Multiple viewports at the same level

  • Access via lifecycle hooks or ICurrentRoute
  • Always validate and convert types

  • Hooks - Component vs Router hooks
  • Viewports - Nested and sibling viewport patterns

  • History - Push vs Replace vs None strategies

  • Transitions - Replace vs invoke-lifecycles behavior

  • Parameters - How data flows from URL to component

  • Navigation Flow
    Lifecycle Hook Execution Order
    Component vs Router Hooks
    Viewport Hierarchy
    History Strategy
    Transition Plans
    Route Parameter Flow
    Route matching documentation →
    Navigation documentation →
    Lifecycle hooks documentation →
    Router hooks →
    Component hooks →
    Viewports documentation →
    History strategy documentation →
    Transition plans documentation →
    Path parameters →
    Query parameters →
    Router Documentation
    ┌──────────────────────────────────────────────────────────┐
    │ 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 validation
    SIMPLE (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;
    }
    Introduction
  • Creating a Basic Custom Attribute

  • Custom Attribute Definition Approaches

    • Convention-Based Approach

    • Decorator-Based Approach

    • Static Definition Approach

  • Explicit Custom Attributes

    • Explicit Attribute Naming

    • Attribute Aliases

  • Single Value Binding

  • Bindable Properties and Change Detection

    • Binding Modes

    • Default Property

    • Bindable Interceptors

  • Options Binding for Multiple Properties

  • Advanced Bindable Configuration

  • Lifecycle Hooks

  • Aggregated Change Callbacks

  • Accessing the Host Element

  • Finding Related Custom Attributes

  • Template Controller Custom Attributes

  • Advanced Configuration Options

  • Definition Metadata Reference

  • Watch Integration

  • Integrating Third-Party Libraries

  • Best Practices


  • hashtag
    Introduction

    Custom attributes are one of the core building blocks in Aurelia 2. Similar to components, they encapsulate behavior and style, but are applied as attributes to existing DOM elements. This makes them especially useful for:

    • Decorating elements with additional styling or behavior.

    • Wrapping third-party libraries that expect to control their own DOM structure.

    • Creating reusable logic that enhances multiple elements across your application.

    • Creating template controllers that control the rendering of content.


    hashtag
    Creating a Basic Custom Attribute

    At its simplest, a custom attribute is defined as a class that enhances an element. Consider this minimal example:

    When you apply a similar pattern using CustomElement instead, you are defining a component. Custom attributes are a more primitive (yet powerful) way to extend behavior without wrapping the entire element in a component.

    hashtag
    Example: Red Square Attribute

    This custom attribute adds a fixed size and a red background to any element it is applied to:

    Usage in HTML:

    The <import> tag ensures that Aurelia's dependency injection is aware of your custom attribute. When applied, the <div> will render with the specified styles.


    hashtag
    Custom Attribute Definition Approaches

    Aurelia 2 provides multiple approaches for defining custom attributes. For most user scenarios, you'll use either the convention-based or decorator-based approach:

    hashtag
    Convention-Based Approach

    Classes ending with CustomAttribute are automatically recognized as custom attributes:

    The attribute name is derived from the class name (red-square in this case).

    hashtag
    Decorator-Based Approach (Recommended)

    Use the @customAttribute decorator for explicit control and better IDE support:

    hashtag
    Static Definition Approach (Framework Internal)

    For completeness, the framework also supports defining attributes using a static $au property. This approach is primarily used by the framework itself to avoid conventions and decorators, but is available if needed:

    circle-info

    When to use each approach:

    • Convention-based: Quick prototyping, simple attributes where the class name matches desired attribute name

    • Decorator-based: Production code, when you need explicit control over naming, aliases, or other configuration

    • Static definition: Advanced scenarios, framework extensions, or when you need to avoid decorators for tooling reasons


    hashtag
    Explicit Custom Attributes

    To gain finer control over your attribute's name and configuration, Aurelia provides the @customAttribute decorator. This lets you explicitly define the attribute name and even set up aliases.

    hashtag
    Explicit Attribute Naming

    By default, the class name might be used to infer the attribute name. However, you can explicitly set a custom name:

    hashtag
    Attribute Aliases

    You can define one or more aliases for your custom attribute. This allows consumers of your attribute flexibility in naming:

    Now the attribute can be used interchangeably using any of the registered names:


    hashtag
    Single Value Binding

    For simple cases, you might want to pass a single value to your custom attribute without explicitly declaring a bindable property. Aurelia will automatically populate the value property if a value is provided.

    Usage:

    To further handle changes in the value over time, you can define the property as bindable:

    Usage with dynamic binding:


    hashtag
    Bindable Properties and Change Detection

    Custom attributes often need to be configurable. Using the @bindable decorator, you can allow users to pass in parameters that change the behavior or style dynamically.

    hashtag
    Binding Modes

    Bindable properties support different binding modes that determine how data flows:

    Available binding modes:

    • BindingMode.toView (default): Data flows from view model to view

    • BindingMode.fromView: Data flows from view to view model

    • BindingMode.twoWay: Data flows both ways

    • BindingMode.oneTime: Data is set once and never updated

    hashtag
    Default Property

    You can specify which property receives the value when the attribute is used with shorthand syntax (without explicitly naming a property). Use the defaultProperty option on the @customAttribute decorator:

    With a default property defined, you can bind directly:

    hashtag
    Bindable Interceptors and Type Coercion

    You can intercept and transform values being set on bindable properties using the set option, or leverage Aurelia's built-in type coercion system. Coercion is opt-in: enable it at startup with config.coercingOptions.enableCoercion = true, and be sure each bindable exposes a runtime type (for example type: Number).

    Built-in Type Coercers:

    • Number: Converts strings to numbers ("123" → 123)

    • String: Converts values to strings (123 → "123")

    • Boolean: Converts values to booleans ("true" → true, "" → false)

    • BigInt: Converts to BigInt values

    • Custom functions: Any function that accepts a value and returns a transformed value

    Advanced Coercion Example:

    hashtag
    Custom Change Callbacks

    You can specify custom callback names for change handlers:


    hashtag
    Options Binding for Multiple Properties

    When you have more than one bindable property, you can use options binding syntax to bind multiple properties at once. This powerful syntax supports complex expressions, binding behaviors, and value converters:

    hashtag
    Basic Options Binding

    hashtag
    Advanced Options Binding Features

    hashtag
    Escaping Special Characters

    Use backslashes to escape colons in URLs or other values:

    hashtag
    Disabling Multi-Binding Parsing

    For attributes that need to handle complex strings without parsing:


    hashtag
    Advanced Bindable Configuration

    You can also define bindables in the static definition or decorator:

    Or using the static $au approach:


    hashtag
    Lifecycle Hooks

    Custom attributes support a comprehensive set of lifecycle hooks that allow you to run code at different stages of their existence:

    • created(controller): Called after the attribute instance is created

    • binding(initiator, parent): Called when data binding begins

    • bound(initiator, parent): Called after data binding is complete

    • attaching(initiator, parent): Called before the element is attached to the DOM

    • attached(initiator): Called after the element is attached to the DOM

    • detaching(initiator, parent): Called before the element is detached from the DOM

    • unbinding(initiator, parent): Called when data binding is being removed

    hashtag
    Example: Using Lifecycle Hooks


    hashtag
    Aggregated Change Callbacks

    Custom attributes provide powerful batching capabilities for handling multiple property changes efficiently:


    hashtag
    Accessing the Host Element

    A key aspect of custom attributes is that they work directly on DOM elements. To manipulate these elements (e.g., updating styles or initializing plugins), you need to access the host element. Aurelia provides a safe way to do this using dependency injection with INode.

    Note: While you can also use resolve(Element) or resolve(HTMLElement), using INode is safer in environments where global DOM constructors might not be available (such as Node.js).


    hashtag
    Finding Related Custom Attributes

    In complex UIs, you might have multiple custom attributes working together (for example, a dropdown with associated toggle buttons). Aurelia offers the CustomAttribute.closest function to traverse the DOM and locate a related custom attribute. This function can search by attribute name or by constructor.

    hashtag
    Example: Searching by Attribute Name

    hashtag
    Example: Searching by Constructor (Type-Safe)

    If you want to search based on the attribute's constructor (for stronger typing), you can do so:

    hashtag
    Practical Use Case: Coordinated Form Validation

    Usage:

    hashtag
    Important Notes

    • DOM Traversal: closest() walks up the DOM tree, checking each ancestor element

    • Multiple Matches: Returns the first (closest) matching attribute found

    • Error Handling: Throws an error if searching by constructor for a class without an attribute definition

    • Performance: Efficient DOM traversal, but cache results if called frequently

    • Type Safety: Constructor-based searches provide better TypeScript support


    hashtag
    Template Controller Custom Attributes

    Custom attributes can also function as template controllers, which control the rendering of content. Template controllers are similar to built-in directives like if.bind and repeat.for.

    hashtag
    Creating a Template Controller

    Usage:

    You can also use the static definition approach:


    hashtag
    Advanced Configuration Options

    Custom attributes support several advanced configuration options:

    hashtag
    No Multi-Bindings

    By default, custom attributes support multiple bindings (attr="prop1: value1; prop2: value2"). You can disable this:

    hashtag
    Dependencies

    You can specify dependencies that should be registered when the attribute is used:

    hashtag
    Container Strategy (Template Controllers Only)

    For template controller custom attributes, you can specify the container strategy to control service isolation:

    Container Strategy Options:

    • 'reuse' (default): Child views share the parent's container

      • More memory efficient

      • Services are singleton across parent and child views

      • Faster view creation

    • 'new': Creates a new container for child views

      • Provides service isolation

      • Each child view gets its own service instances

    When to Use Container Isolation:

    hashtag
    Definition Metadata Reference

    Decorators and conventions eventually funnel into a PartialCustomAttributeDefinition, defined in @aurelia/runtime-html. Knowing every field on that definition unlocks advanced behaviors without sprinkling ad-hoc logic through your class. The table below summarizes the metadata you can provide (either via decorators or by calling CustomAttribute.define()/CustomAttributeDefinition.create() yourself):

    Property
    Type
    Description

    name

    string

    The canonical attribute name. When omitted, Aurelia infers it from the class name.

    aliases

    string[]

    Additional attribute names that should map to the same implementation. Use when you need both awesome-slider and awesomeSlider.

    hashtag
    Inspecting definitions at runtime

    CustomAttributeDefinition.getDefinition(MyAttribute) returns the normalized definition object—including inferred defaults and decorator metadata. That makes it simple to write tooling or plugin code that reacts to attribute settings:

    hashtag
    Creating attributes without decorators

    When you need to generate attributes dynamically (for example, inside a plugin) call CustomAttribute.define or CustomAttributeDefinition.create with a PartialCustomAttributeDefinition:

    Because all of these options live on the definition, you keep your constructor and lifecycle hooks focused on runtime behavior while the metadata decides how the attribute integrates with the templating pipeline.


    hashtag
    Watch Integration

    Custom attributes can integrate with Aurelia's @watch decorator for advanced property observation:


    hashtag
    Integrating Third-Party Libraries

    Often, you'll want to incorporate functionality from third-party libraries—such as sliders, date pickers, or custom UI components—into your Aurelia applications. Custom attributes provide an excellent way to encapsulate the integration logic, ensuring that the third-party library initializes, updates, and cleans up properly within Aurelia's lifecycle.

    hashtag
    When to Use Custom Attributes for Integration

    • DOM Manipulation: Many libraries require direct access to the DOM element for initialization.

    • Lifecycle Management: You can leverage Aurelia's lifecycle hooks (attached() and detaching()) to manage resource allocation and cleanup.

    • Dynamic Updates: With bindable properties, you can pass configuration options to the library and update it reactively when those options change.

    hashtag
    Example: Integrating a Hypothetical Slider Library

    Consider a third-party slider library called AwesomeSlider that initializes a slider on a given DOM element. Below is an example of how to wrap it in a custom attribute.

    In place of our hypothetical AwesomeSlider library, you can use any third-party library that requires DOM manipulation such as jQuery plugins, D3.js, or even custom UI components.


    hashtag
    Best Practices

    hashtag
    Separation of Concerns

    Keep your custom attribute logic focused on enhancing the host element, and avoid heavy business logic. Custom attributes should be presentational or behavioral enhancements, not data processing units.

    hashtag
    Performance

    • Minimize DOM manipulations: Cache style properties and batch updates when possible

    • Use propertiesChanged: For multiple property changes, batch updates to reduce DOM thrashing

    • Lifecycle hook timing: Use appropriate hooks for initialization

      • constructor(): Basic setup, non-DOM operations

      • attached(): DOM-dependent initialization, third-party library setup

      • detaching(): Cleanup before DOM removal

    hashtag
    Memory Management

    • Clean up event listeners: Always remove event listeners to prevent memory leaks

    • Dispose third-party instances: Call proper cleanup methods for external libraries

    • Weak references: Use WeakMap/WeakSet for object references when appropriate

    hashtag
    Error Handling

    • Graceful degradation: Handle initialization failures gracefully

    • Validation: Validate bindable property values

    • Logging: Use Aurelia's logging system for debugging

    hashtag
    Testing

    Write comprehensive unit tests covering lifecycle hooks, property changes, and edge cases:

    hashtag
    Documentation and Maintainability

    • Document public APIs: Clearly document bindable properties and their expected types

    • Use meaningful names: Choose descriptive names for attributes and properties

    • Provide usage examples: Include HTML usage examples in comments

    • Type everything: Use strong TypeScript typing for better IDE support

    hashtag
    Type Safety Best Practices

    hashtag
    Advanced Features Summary

    hashtag
    Computed Bindables with Getters

    Custom attributes support getter-based bindables for computed properties:

    hashtag
    Bindable Inheritance

    Bindable properties properly inherit from parent classes:

    hashtag
    Error Handling and Lifecycle Management

    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);
      }
    }
    Useful for plugin systems or complex nested scenarios

    bindables

    Record<string, PartialBindableDefinition> or array

    Declares bindable inputs. You can mix shorthand strings with full objects { name, mode, callback, attribute }.

    defaultProperty

    string

    The name of the property that receives the value when using shorthand syntax (e.g., my-attr="value"). Defaults to 'value'.

    isTemplateController

    boolean

    Marks the attribute as a template controller so Aurelia replaces the host element with the controller's view.

    noMultiBindings

    boolean

    Treats the attribute value as a single literal string instead of prop: value pairs. Useful for URLs and DSL-like syntaxes.

    watches

    IWatchDefinition[]

    Registers @watch entries without decorators—great for framework-level attributes or generated code.

    dependencies

    Key[]

    Additional registrations to install when the attribute definition is added to a container (for example, helper services).

    containerStrategy

    'reuse' | 'new'

    Controls whether template controllers reuse the parent container or spin up an isolated child container.

    Custom Change Callbacks

    Advanced Patterns

    Complex form scenarios with multi-step wizards, dynamic fields, conditional validation, and more.

    circle-check

    What you'll learn...

    • Multi-step wizard forms with progress tracking

    • Dynamic forms (add/remove fields at runtime)

    • Conditional validation based on field dependencies

    • Form state management (dirty, pristine, touched)

    • Autosave and draft management

    • Complex file uploads with preview and progress

    • Form arrays (repeating field groups)

    hashtag
    Prerequisites

    All examples assume you have the validation plugin installed and configured:

    See for setup details.


    hashtag
    Multi-Step Wizard Forms

    Multi-step forms break complex forms into manageable steps, improving user experience and completion rates.

    hashtag
    Complete Example: User Onboarding Wizard

    Key Features:

    • Step-by-step validation (only validate current step)

    • Progress indicator

    • Conditional validation rules with .when()


    hashtag
    Dynamic Forms (Add/Remove Fields)

    Forms where users can add or remove fields at runtime, like adding multiple email addresses or phone numbers.

    hashtag
    Complete Example: Contact Form with Dynamic Emails

    Key Features:

    • Add/remove email entries dynamically

    • Ensure at least one email exists

    • Automatically handle primary email when removing

    • Validate all emails in the array


    hashtag
    Conditional Validation (Field Dependencies)

    Validation rules that change based on the values of other fields.

    hashtag
    Complete Example: Shipping Form

    Key Features:

    • Conditional field visibility with if.bind

    • Conditional validation with .when()

    • Fields depend on checkbox state


    hashtag
    Form State Management (Dirty, Pristine, Touched)

    Track whether forms have been modified and warn users before losing changes.

    hashtag
    Complete Example: Article Editor with Unsaved Changes Warning

    Key Features:

    • Dirty state tracking (compare current vs original)

    • Autosave to localStorage every 30 seconds

    • Last saved timestamp with human-readable formatting

    • Prevent navigation with canUnload


    hashtag
    Form Arrays (Repeating Field Groups)

    Form arrays allow users to add/remove entire groups of fields, like invoice line items or multiple addresses.

    hashtag
    Complete Example: Invoice Form with Line Items

    Key Features:

    • Add/remove line items dynamically

    • Duplicate line items

    • Auto-calculate line totals and invoice totals

    • Validate entire array of items


    hashtag
    Complex File Uploads with Preview & Progress

    Handle multiple file uploads with image previews, progress tracking, and validation.

    hashtag
    Complete Example: Image Gallery Upload

    Key Features:

    • Drag & drop support

    • Image preview generation

    • Progress tracking for each file

    • File type and size validation


    hashtag
    Dependent Dropdowns (Cascading Selects)

    Dropdowns where options depend on previous selections, like country → state → city.

    hashtag
    Complete Example: Location Selector

    Key Features:

    • Cascading selects (country → state → city)

    • Computed properties for filtered options

    • Auto-reset dependent fields when parent changes

    • Loading states while fetching options


    hashtag
    Reusable Form Field Components

    Create reusable form field components that encapsulate label, input, validation, and error display.

    hashtag
    Complete Example: Validated Text Field Component

    hashtag
    Usage Example

    Key Features:

    • Encapsulates label, input, validation, error display

    • Reusable across entire application

    • Consistent styling and behavior

    • Accessible (proper ARIA attributes)


    hashtag
    Related Documentation

    • - Basic form concepts

    • - Complete validation guide

    • - Handling form submission


    hashtag
    Summary

    These advanced patterns handle complex real-world scenarios:

    1. Multi-step wizards - Break complex forms into manageable steps with conditional validation and progress tracking

    2. Dynamic forms - Add/remove individual fields at runtime with validation

    3. Conditional validation - Validation rules that depend on other field values

    All examples use proper Aurelia 2 syntax with the validation plugin and follow accessibility best practices. Each pattern includes complete, production-ready code with TypeScript interfaces, validation rules, and accessible HTML templates.

    Navigate to first step with errors on final submit
  • Accessible navigation buttons

  • Unique IDs for each entry (accessibility and key binding)

    Fields depend on select values

  • Automatic revalidation when dependencies change

  • router hook
  • Visual indicators for save state

  • Disable actions while saving

  • Prevent removing last item

  • Unique IDs for each line item

  • Accessible labels for screen readers

  • Multiple file selection

  • Individual or bulk upload

  • Error handling per file

  • Visual status indicators

  • Disabled state until parent is selected

  • Helpful hints for users

  • Reduced boilerplate in forms

    - Working with form collections
  • - File upload patterns

  • Form state management - Track changes, prevent data loss, implement autosave with router guards
  • Form arrays - Repeating field groups (like invoice line items) with add/remove/duplicate functionality

  • Complex file uploads - Multiple file uploads with drag & drop, previews, progress tracking, and per-file validation

  • Dependent dropdowns - Cascading selects (country → state → city) with auto-reset and loading states

  • Reusable form fields - Encapsulated field components with built-in validation display

  • 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>
    Validation documentation
    Form Basics
    Validation Plugin
    Form Submission
    Collections (Checkboxes, Radios, Select)
    File Uploads

    Comprehensive Reference

    Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.

    Forms are the cornerstone of interactive web applications. Whether you're building simple contact forms, complex data-entry systems, or dynamic configuration interfaces, Aurelia provides a comprehensive and performant forms system. This guide covers everything from basic input binding to advanced patterns like collection-based form controls, dynamic form generation, and seamless validation integration.

    circle-check

    Looking for focused guides? This is a comprehensive reference covering all form concepts. For more digestible, task-focused guides, check out:

    • - Text inputs, textareas, number/date inputs

    • - Checkboxes, radio buttons, select elements, Sets, Maps

    • - Handling form submission, state management, auto-save

    • - File input handling, validation, progress tracking

    • - Multi-step wizards, dynamic forms, conditional validation, form state management

    circle-info

    This guide assumes familiarity with Aurelia's binding system and template syntax. For fundamentals, see first.

    hashtag
    Table of Contents


    hashtag
    Understanding Aurelia's Form Architecture

    Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls. Understanding this architecture helps you build more efficient and maintainable forms.

    hashtag
    Data Flow Architecture

    Key Components:

    1. Observers: Monitor DOM events and property changes

    2. Bindings: Connect observers to view model properties

    3. Collection Observers: Handle arrays, Sets, and Maps efficiently

    hashtag
    Automatic Change Detection

    Aurelia automatically observes:

    • Text inputs: input, change, keyup events

    • Checkboxes/Radio: change events with array synchronization

    This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.


    hashtag
    Basic Input Binding

    Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals and build toward advanced patterns.

    hashtag
    Simple Text Inputs

    The foundation of most forms is text input binding:

    hashtag
    Textarea Binding

    Textareas work identically to text inputs:

    hashtag
    Number and Date Inputs

    Browser form controls always provide string values unless you bind to their typed DOM properties. Use Aurelia's value-as-* bindings when you need numbers or dates in your view-model.

    value-as-number binds to the input's valueAsNumber, so age is a number (or NaN when the field is empty/invalid). value-as-date binds to valueAsDate, giving you a Date | null. If you keep value.bind, the value remains a string—be sure to convert it before serializing to JSON for APIs.


    hashtag
    Binding With Text and Textarea Inputs

    hashtag
    Text Input

    Binding to text inputs in Aurelia is straightforward:

    You can also bind other attributes like placeholder:

    hashtag
    Textarea

    Textareas work just like text inputs, with value.bind for two-way binding:

    Any changes to textAreaValue in the view model will show up in the <textarea>, and vice versa.


    hashtag
    Advanced Collection Patterns

    One of Aurelia's most powerful features is its sophisticated support for collection-based form controls. Beyond simple arrays, Aurelia supports Sets, Maps, and custom collection types with optimal performance characteristics.

    hashtag
    Boolean Checkboxes

    The simplest checkbox pattern binds to boolean properties:

    hashtag
    Array-Based Multi-Select

    For traditional multi-select scenarios, bind arrays to checkbox groups:

    hashtag
    Set-Based Collections (Advanced)

    For high-performance scenarios with frequent additions/removals, use Set collections:

    hashtag
    Resource-Keyed Collections (Expert Level)

    hashtag
    Per-Resource Permission Sets (Expert Level)

    For complex key-value selections (e.g., multiple actions per resource), keep a Set per resource so each checkbox can reuse Aurelia's built-in collection handling:

    hashtag
    Performance Considerations

    Choose the right collection type for your use case:

    • Arrays: General purpose, good for small to medium collections

    • Sets: High-performance for frequent additions/removals, O(1) lookups

    • Record/Map of Sets: Model resource → actions (or similar hierarchies) cleanly

    Performance Tips:

    • Use Set for large collections with frequent changes

    • Implement efficient matcher functions for object comparison

    • Avoid creating new objects in templates—use computed properties

    • Consider virtualization for very large checkbox lists


    hashtag
    Event Handling and Binding Behaviors

    Aurelia provides sophisticated event handling and binding behaviors that give you precise control over when and how form data synchronizes. These features are crucial for building responsive, performant forms.

    hashtag
    Advanced Event Timing with updateTrigger

    By default, Aurelia uses appropriate events for each input type, but you can customize this behavior:

    hashtag
    Rate Limiting with Debounce and Throttle

    Control the frequency of updates to improve performance and user experience:

    hashtag
    Signal-Based Reactive Updates

    Signals provide cache invalidation and coordinated updates across components:


    hashtag
    Dynamic Forms and Performance

    Building performant, dynamic forms requires understanding Aurelia's observation system and applying optimization strategies for complex scenarios.

    hashtag
    Dynamic Field Generation

    Create forms that adapt their structure based on configuration:

    hashtag
    Performance Optimization Strategies

    Implement performance optimizations for large, complex forms:


    hashtag
    Radio Button and Select Element Patterns

    Aurelia provides comprehensive support for single-selection controls with sophisticated object binding and custom matching logic.

    hashtag
    Advanced Radio Button Groups

    Radio buttons with complex object handling and conditional logic:

    hashtag
    Advanced Select Elements with Smart Filtering

    Sophisticated select components with search, grouping, and virtual scrolling:


    hashtag
    Form Submission Patterns

    Modern web applications require sophisticated form submission strategies that handle success, failure, loading states, and complex business logic. Aurelia provides flexible patterns for all scenarios.

    hashtag
    Basic Form Submission with State Management

    Implement comprehensive submission state management for better user experience:

    hashtag
    Multi-Step Form Submission

    Handle complex multi-step forms with progress tracking and validation:


    hashtag
    File Inputs and Upload Handling

    Working with file uploads in Aurelia typically involves using the standard <input type="file"> element and handling file data in your view model. While Aurelia doesn’t provide special bindings for file inputs, you can easily wire up event handlers or use standard properties to capture and upload files.

    hashtag
    Capturing File Data

    In most cases, you’ll want to listen for the change event on a file input:

    • multiple: Allows selecting more than one file.

    • accept="image/*": Restricts file selection to images (this can be changed to fit your needs).

    • change.trigger="handleFileSelect($event)": Calls a method in your view model to handle the file selection event.

    hashtag
    View Model Handling

    You can retrieve the selected files from the event object in your view model:

    Key Points:

    • Reading File Data: input.files returns a FileList; converting it to an array (Array.from) makes it easier to iterate over.

    • FormData: Using FormData to append files is a convenient way to send them to the server (via Fetch).

    hashtag
    Single File Inputs

    If you only need a single file, omit multiple and simplify your logic:

    hashtag
    Validation and Security

    When handling file uploads, consider adding validation and security measures:

    • Server-side Validation: Even if you filter files by type on the client (accept="image/*"), always verify on the server to ensure the files are valid and safe.

    • File Size Limits: Check file sizes either on the client or server (or both) to prevent excessively large uploads.

    • Progress Indicators: For a better user experience, consider using XMLHttpRequest or the Fetch API with progress events (via third-party solutions or polyfills), so you can display an upload progress bar.


    hashtag
    Validation Integration

    Aurelia's validation system integrates seamlessly with forms through the & validate binding behavior and specialized validation components. This section covers practical validation patterns for production applications.

    hashtag
    Basic Validation with & validate

    The & validate binding behavior automatically integrates form inputs with Aurelia's validation system.

    circle-info

    Validation Controller Scope: Use newInstanceForScope(IValidationController) to create a validation controller scoped to your component. This ensures each component gets its own isolated validation controller, preventing conflicts between different forms or components.

    hashtag
    Advanced Validation Display

    Use validation components for sophisticated error display:

    hashtag
    Dynamic Validation Rules

    Create validation rules that adapt to changing form conditions:

    hashtag
    Real-time Validation Feedback

    Provide immediate feedback with sophisticated validation timing:

    For comprehensive validation documentation, see the dedicated .


    hashtag
    Security and Best Practices

    Security in forms is critical for protecting user data and preventing common web vulnerabilities. Aurelia provides the foundation for secure form implementations, but you must implement security best practices.

    hashtag
    Input Validation and Sanitization

    Always validate and sanitize user input on both client and server sides:

    hashtag
    Rate Limiting and Abuse Prevention

    Implement client-side rate limiting and abuse prevention:

    hashtag
    Content Security Policy (CSP) Considerations

    Implement CSP-friendly form patterns:


    hashtag
    Accessibility Considerations

    Building accessible forms ensures your application works for users with disabilities and meets WCAG guidelines. Aurelia provides excellent support for accessibility features.

    hashtag
    Semantic Form Structure

    Use proper semantic HTML and ARIA attributes:

    hashtag
    Accessible Form Validation

    Implement validation feedback that works with screen readers:

    hashtag
    CSS for Accessibility

    Include essential accessibility styles:

    hashtag
    Testing Accessibility

    Include accessibility testing strategies:

    This comprehensive forms documentation provides production-ready patterns for all aspects of form development in Aurelia 2, from basic binding to advanced security and accessibility considerations. Each section includes real-world examples that you can adapt to your specific use cases.

  • Mutation Observers: Track dynamic DOM changes
  • Value Converters & Binding Behaviors: Transform and control data flow

  • Select elements: change events with mutation observation
  • Collections: Array mutations, Set/Map changes

  • Object properties: Deep property observation

  • Custom Matchers: When object identity comparison isn't sufficient
    Error Handling: Always check response.ok to handle server or network errors.
  • Disabling the Button: In the HTML, disabled.bind="!selectedFiles.length" keeps the button disabled until at least one file is selected.

  • 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>
    file-upload-component.html
    <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>
    file-upload-component.ts
    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, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;')
          .replace(/'/g, '&#x27;');
        
        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
      }
    }
    Form Basics
    Collections
    Form Submission
    File Uploads
    Advanced Patterns
    Template Syntax & Features
    Understanding Aurelia's Form Architecture
    Basic Input Binding
    Advanced Collection Patterns
    Dynamic Forms and Performance
    Validation Guide
    Event Handling and Binding Behaviors
    Validation Integration
    File Upload Handling
    Form Submission Patterns
    Security and Best Practices
    Accessibility Considerations