arrow-left

All pages
gitbookPowered by GitBook
1 of 1

Loading...

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.

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