576 lines
14 KiB
Markdown
576 lines
14 KiB
Markdown
# 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<HTMLElement>);
|
|
|
|
enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
|
|
delay = input(0);
|
|
|
|
constructor() {
|
|
afterNextRender(() => {
|
|
if (this.enabled()) {
|
|
setTimeout(() => {
|
|
this.el.nativeElement.focus();
|
|
}, this.delay());
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Usage: <input appAutoFocus />
|
|
// Usage: <input [appAutoFocus]="shouldFocus()" [delay]="100" />
|
|
```
|
|
|
|
### Text Selection Directive
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appSelectAll]',
|
|
host: {
|
|
'(focus)': 'onFocus()',
|
|
'(click)': 'onClick($event)',
|
|
},
|
|
})
|
|
export class SelectAll {
|
|
private el = inject(ElementRef<HTMLInputElement>);
|
|
|
|
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: <input appSelectAll value="Select me on focus" />
|
|
```
|
|
|
|
### Copy to Clipboard
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appCopyToClipboard]',
|
|
host: {
|
|
'(click)': 'copy()',
|
|
'[style.cursor]': '"pointer"',
|
|
},
|
|
})
|
|
export class CopyToClipboard {
|
|
text = input.required<string>({ alias: 'appCopyToClipboard' });
|
|
|
|
copied = output<void>();
|
|
error = output<Error>();
|
|
|
|
async copy() {
|
|
try {
|
|
await navigator.clipboard.writeText(this.text());
|
|
this.copied.emit();
|
|
} catch (err) {
|
|
this.error.emit(err as Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Usage:
|
|
// <button [appCopyToClipboard]="textToCopy" (copied)="showToast('Copied!')">
|
|
// Copy
|
|
// </button>
|
|
```
|
|
|
|
## Form Directives
|
|
|
|
### Trim Input
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: 'input[appTrim], textarea[appTrim]',
|
|
host: {
|
|
'(blur)': 'onBlur()',
|
|
},
|
|
})
|
|
export class Trim {
|
|
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
|
|
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 appTrim formControlName="name" />
|
|
```
|
|
|
|
### Input Mask
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appMask]',
|
|
host: {
|
|
'(input)': 'onInput($event)',
|
|
'(keydown)': 'onKeydown($event)',
|
|
},
|
|
})
|
|
export class Mask {
|
|
private el = inject(ElementRef<HTMLInputElement>);
|
|
|
|
// Mask pattern: 9 = digit, A = letter, * = any
|
|
mask = input.required<string>({ 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: <input appMask="(999) 999-9999" placeholder="(555) 123-4567" />
|
|
```
|
|
|
|
### Character Counter
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appCharCount]',
|
|
})
|
|
export class CharCount {
|
|
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
|
|
|
|
maxLength = input.required<number>({ 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:
|
|
// <textarea appCharCount="500" #counter="appCharCount"></textarea>
|
|
// <span>{{ counter.remaining() }} characters remaining</span>
|
|
```
|
|
|
|
## Intersection Observer
|
|
|
|
### Lazy Load Directive
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appLazyLoad]',
|
|
})
|
|
export class LazyLoad implements OnDestroy {
|
|
private el = inject(ElementRef<HTMLElement>);
|
|
private observer: IntersectionObserver | null = null;
|
|
|
|
src = input.required<string>({ alias: 'appLazyLoad' });
|
|
placeholder = input('/assets/placeholder.png');
|
|
|
|
loaded = output<void>();
|
|
|
|
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: <img [appLazyLoad]="imageUrl" alt="Lazy loaded image" />
|
|
```
|
|
|
|
### Infinite Scroll
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appInfiniteScroll]',
|
|
})
|
|
export class InfiniteScroll implements OnDestroy {
|
|
private el = inject(ElementRef<HTMLElement>);
|
|
private observer: IntersectionObserver | null = null;
|
|
|
|
threshold = input(0.1);
|
|
disabled = input(false);
|
|
|
|
scrolled = output<void>();
|
|
|
|
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:
|
|
// <div class="list">
|
|
// @for (item of items(); track item.id) {
|
|
// <div>{{ item.name }}</div>
|
|
// }
|
|
// <div appInfiniteScroll (scrolled)="loadMore()" [disabled]="isLoading()">
|
|
// Loading...
|
|
// </div>
|
|
// </div>
|
|
```
|
|
|
|
## Resize Observer
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appResize]',
|
|
})
|
|
export class Resize implements OnDestroy {
|
|
private el = inject(ElementRef<HTMLElement>);
|
|
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:
|
|
// <div appResize #resize="appResize">
|
|
// Size: {{ resize.width() }}x{{ resize.height() }}
|
|
// </div>
|
|
```
|
|
|
|
## Drag and Drop
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appDraggable]',
|
|
host: {
|
|
'draggable': 'true',
|
|
'[class.dragging]': 'isDragging()',
|
|
'(dragstart)': 'onDragStart($event)',
|
|
'(dragend)': 'onDragEnd($event)',
|
|
},
|
|
})
|
|
export class Draggable {
|
|
data = input<any>(null, { alias: 'appDraggable' });
|
|
effectAllowed = input<DataTransfer['effectAllowed']>('move');
|
|
|
|
isDragging = signal(false);
|
|
|
|
dragStart = output<DragEvent>();
|
|
dragEnd = output<DragEvent>();
|
|
|
|
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<any>();
|
|
|
|
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:
|
|
// <div [appDraggable]="item">Drag me</div>
|
|
// <div appDropZone (dropped)="onItemDropped($event)">Drop here</div>
|
|
```
|
|
|
|
## Permission Directive
|
|
|
|
```typescript
|
|
@Directive({
|
|
selector: '[appHasPermission]',
|
|
})
|
|
export class HasPermission {
|
|
private templateRef = inject(TemplateRef<any>);
|
|
private viewContainer = inject(ViewContainerRef);
|
|
private authService = inject(Auth);
|
|
private hasView = false;
|
|
|
|
permission = input.required<string | string[]>({ 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:
|
|
// <button *appHasPermission="'admin'">Admin Only</button>
|
|
// <div *appHasPermission="['edit', 'delete']; mode: 'all'">Edit & Delete</div>
|
|
```
|
|
|
|
## 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:
|
|
// <div appToggle #toggle="appToggle">
|
|
// <button (click)="toggle.toggle()">Toggle</button>
|
|
// @if (toggle.isOpen()) {
|
|
// <div>Content</div>
|
|
// }
|
|
// </div>
|
|
```
|