# Angular Directive Patterns ## Table of Contents - [DOM Manipulation](#dom-manipulation) - [Form Directives](#form-directives) - [Intersection Observer](#intersection-observer) - [Resize Observer](#resize-observer) - [Drag and Drop](#drag-and-drop) - [Permission Directive](#permission-directive) ## DOM Manipulation ### Auto-Focus Directive ```typescript @Directive({ selector: "[appAutoFocus]", }) export class AutoFocus { private el = inject(ElementRef); enabled = input(true, { alias: "appAutoFocus", transform: booleanAttribute }); delay = input(0); constructor() { afterNextRender(() => { if (this.enabled()) { setTimeout(() => { this.el.nativeElement.focus(); }, this.delay()); } }); } } // Usage: // Usage: ``` ### Text Selection Directive ```typescript @Directive({ selector: "[appSelectAll]", host: { "(focus)": "onFocus()", "(click)": "onClick($event)", }, }) export class SelectAll { private el = inject(ElementRef); onFocus() { // Delay to ensure value is set setTimeout(() => this.el.nativeElement.select(), 0); } onClick(event: MouseEvent) { // Select all on first click if not already focused if (document.activeElement !== this.el.nativeElement) { this.el.nativeElement.select(); } } } // Usage: ``` ### Copy to Clipboard ```typescript @Directive({ selector: "[appCopyToClipboard]", host: { "(click)": "copy()", "[style.cursor]": '"pointer"', }, }) export class CopyToClipboard { text = input.required({ alias: "appCopyToClipboard" }); copied = output(); error = output(); async copy() { try { await navigator.clipboard.writeText(this.text()); this.copied.emit(); } catch (err) { this.error.emit(err as Error); } } } // Usage: // ``` ## Form Directives ### Trim Input ```typescript @Directive({ selector: "input[appTrim], textarea[appTrim]", host: { "(blur)": "onBlur()", }, }) export class Trim { private el = inject(ElementRef); private ngControl = inject(NgControl, { optional: true, self: true }); onBlur() { const value = this.el.nativeElement.value; const trimmed = value.trim(); if (value !== trimmed) { this.el.nativeElement.value = trimmed; this.ngControl?.control?.setValue(trimmed); } } } // Usage: ``` ### Input Mask ```typescript @Directive({ selector: "[appMask]", host: { "(input)": "onInput($event)", "(keydown)": "onKeydown($event)", }, }) export class Mask { private el = inject(ElementRef); // Mask pattern: 9 = digit, A = letter, * = any mask = input.required({ alias: "appMask" }); onInput(event: InputEvent) { const input = this.el.nativeElement; const value = input.value; const masked = this.applyMask(value); if (value !== masked) { input.value = masked; } } onKeydown(event: KeyboardEvent) { // Allow navigation keys if ( ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes( event.key, ) ) { return; } const input = this.el.nativeElement; const position = input.selectionStart ?? 0; const maskChar = this.mask()[position]; if (!maskChar) { event.preventDefault(); return; } if (!this.isValidChar(event.key, maskChar)) { event.preventDefault(); } } private applyMask(value: string): string { const mask = this.mask(); let result = ""; let valueIndex = 0; for (let i = 0; i < mask.length && valueIndex < value.length; i++) { const maskChar = mask[i]; const inputChar = value[valueIndex]; if (maskChar === "9" || maskChar === "A" || maskChar === "*") { if (this.isValidChar(inputChar, maskChar)) { result += inputChar; valueIndex++; } else { valueIndex++; i--; } } else { result += maskChar; if (inputChar === maskChar) { valueIndex++; } } } return result; } private isValidChar(char: string, maskChar: string): boolean { switch (maskChar) { case "9": return /\d/.test(char); case "A": return /[a-zA-Z]/.test(char); case "*": return /[a-zA-Z0-9]/.test(char); default: return char === maskChar; } } } // Usage: ``` ### Character Counter ```typescript @Directive({ selector: "[appCharCount]", }) export class CharCount { private el = inject(ElementRef); maxLength = input.required({ alias: "appCharCount" }); currentLength = signal(0); remaining = computed(() => this.maxLength() - this.currentLength()); isOverLimit = computed(() => this.remaining() < 0); constructor() { effect(() => { this.currentLength.set(this.el.nativeElement.value.length); }); // Listen for input changes afterNextRender(() => { this.el.nativeElement.addEventListener("input", () => { this.currentLength.set(this.el.nativeElement.value.length); }); }); } } // Usage with template: // // {{ counter.remaining() }} characters remaining ``` ## Intersection Observer ### Lazy Load Directive ```typescript @Directive({ selector: "[appLazyLoad]", }) export class LazyLoad implements OnDestroy { private el = inject(ElementRef); private observer: IntersectionObserver | null = null; src = input.required({ alias: "appLazyLoad" }); placeholder = input("/assets/placeholder.png"); loaded = output(); constructor() { afterNextRender(() => { this.setupObserver(); }); } private setupObserver() { this.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { this.loadImage(); this.observer?.disconnect(); } }); }, { rootMargin: "50px" }, ); this.observer.observe(this.el.nativeElement); // Set placeholder if (this.el.nativeElement instanceof HTMLImageElement) { this.el.nativeElement.src = this.placeholder(); } } private loadImage() { const element = this.el.nativeElement; if (element instanceof HTMLImageElement) { element.src = this.src(); element.onload = () => this.loaded.emit(); } else { element.style.backgroundImage = `url(${this.src()})`; this.loaded.emit(); } } ngOnDestroy() { this.observer?.disconnect(); } } // Usage: Lazy loaded image ``` ### Infinite Scroll ```typescript @Directive({ selector: "[appInfiniteScroll]", }) export class InfiniteScroll implements OnDestroy { private el = inject(ElementRef); private observer: IntersectionObserver | null = null; threshold = input(0.1); disabled = input(false); scrolled = output(); constructor() { afterNextRender(() => { this.setupObserver(); }); effect(() => { if (this.disabled()) { this.observer?.disconnect(); } else { this.setupObserver(); } }); } private setupObserver() { this.observer?.disconnect(); this.observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !this.disabled()) { this.scrolled.emit(); } }, { threshold: this.threshold() }, ); this.observer.observe(this.el.nativeElement); } ngOnDestroy() { this.observer?.disconnect(); } } // Usage: //
// @for (item of items(); track item.id) { //
{{ item.name }}
// } //
// Loading... //
//
``` ## Resize Observer ```typescript @Directive({ selector: "[appResize]", }) export class Resize implements OnDestroy { private el = inject(ElementRef); private observer: ResizeObserver | null = null; width = signal(0); height = signal(0); resized = output<{ width: number; height: number }>(); constructor() { afterNextRender(() => { this.observer = new ResizeObserver((entries) => { const entry = entries[0]; const { width, height } = entry.contentRect; this.width.set(width); this.height.set(height); this.resized.emit({ width, height }); }); this.observer.observe(this.el.nativeElement); }); } ngOnDestroy() { this.observer?.disconnect(); } } // Usage: //
// Size: {{ resize.width() }}x{{ resize.height() }} //
``` ## Drag and Drop ```typescript @Directive({ selector: "[appDraggable]", host: { draggable: "true", "[class.dragging]": "isDragging()", "(dragstart)": "onDragStart($event)", "(dragend)": "onDragEnd($event)", }, }) export class Draggable { data = input(null, { alias: "appDraggable" }); effectAllowed = input("move"); isDragging = signal(false); dragStart = output(); dragEnd = output(); onDragStart(event: DragEvent) { this.isDragging.set(true); if (event.dataTransfer) { event.dataTransfer.effectAllowed = this.effectAllowed(); event.dataTransfer.setData( "application/json", JSON.stringify(this.data()), ); } this.dragStart.emit(event); } onDragEnd(event: DragEvent) { this.isDragging.set(false); this.dragEnd.emit(event); } } @Directive({ selector: "[appDropZone]", host: { "[class.drag-over]": "isDragOver()", "(dragover)": "onDragOver($event)", "(dragleave)": "onDragLeave($event)", "(drop)": "onDrop($event)", }, }) export class DropZone { isDragOver = signal(false); dropped = output(); onDragOver(event: DragEvent) { event.preventDefault(); this.isDragOver.set(true); } onDragLeave(event: DragEvent) { this.isDragOver.set(false); } onDrop(event: DragEvent) { event.preventDefault(); this.isDragOver.set(false); const data = event.dataTransfer?.getData("application/json"); if (data) { this.dropped.emit(JSON.parse(data)); } } } // Usage: //
Drag me
//
Drop here
``` ## Permission Directive ```typescript @Directive({ selector: "[appHasPermission]", }) export class HasPermission { private templateRef = inject(TemplateRef); private viewContainer = inject(ViewContainerRef); private authService = inject(Auth); private hasView = false; permission = input.required({ alias: "appHasPermission" }); mode = input<"any" | "all">("any"); constructor() { effect(() => { const hasPermission = this.checkPermission(); if (hasPermission && !this.hasView) { this.viewContainer.createEmbeddedView(this.templateRef); this.hasView = true; } else if (!hasPermission && this.hasView) { this.viewContainer.clear(); this.hasView = false; } }); } private checkPermission(): boolean { const required = this.permission(); const permissions = Array.isArray(required) ? required : [required]; const userPermissions = this.authService.permissions(); if (this.mode() === "all") { return permissions.every((p) => userPermissions.includes(p)); } return permissions.some((p) => userPermissions.includes(p)); } } // Usage: // //
Edit & Delete
``` ## Export Directive Reference ```typescript @Directive({ selector: "[appToggle]", exportAs: "appToggle", }) export class Toggle { isOpen = signal(false); toggle() { this.isOpen.update((v) => !v); } open() { this.isOpen.set(true); } close() { this.isOpen.set(false); } } // Usage: //
// // @if (toggle.isOpen()) { //
Content
// } //
```