# 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
// } //
```