Advanced patterns for building custom attributes in Aurelia 2, including template controllers, complex bindings, and performance optimization.
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();
}
}