# Angular Directives

> Part of mk:angular skill. Read when: working with directives.
> Source: analogjs/angular-skills/angular-directives (MIT)

## Contents

- [Core Patterns](#core-patterns)
- [Advanced Patterns](#advanced-patterns)


## Core Patterns

**Attribute Directives:**
```typescript
import { Directive, input, effect, inject, ElementRef } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
})
export class Highlight {
  private el = inject(ElementRef<HTMLElement>);
  
  // Input with alias matching selector
  color = input('yellow', { alias: 'appHighlight' });
  
  constructor() {
    effect(() => {
      this.el.nativeElement.style.backgroundColor = this.color();
    });
  }
}

// Usage: <p appHighlight="lightblue">Highlighted text</p>
// Usage: <p appHighlight>Default yellow highlight</p>
```

**Using host Property (prefer over @HostBinding/@HostListener):**
```typescript
@Directive({
  selector: '[appTooltip]',
  host: {
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()',
    '[attr.aria-describedby]': 'tooltipId',
  },
})
export class Tooltip {
  text = input.required<string>({ alias: 'appTooltip' });
  position = input<'top' | 'bottom' | 'left' | 'right'>('top');
  
  tooltipId = `tooltip-${crypto.randomUUID()}`;
  private tooltipEl: HTMLElement | null = null;
  private el = inject(ElementRef<HTMLElement>);
  
  show() {
    this.tooltipEl = document.createElement('div');
    this.tooltipEl.id = this.tooltipId;
    this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
    this.tooltipEl.textContent = this.text();
    this.tooltipEl.setAttribute('role', 'tooltip');
    document.body.appendChild(this.tooltipEl);
    this.positionTooltip();
  }
  
  hide() {
    this.tooltipEl?.remove();
    this.tooltipEl = null;
  }
  
  private positionTooltip() {
    // Position logic based on this.position() and this.el
  }
}

// Usage: <button appTooltip="Click to save" position="bottom">Save</button>
```

**Class and Style Manipulation:**
```typescript
@Directive({
  selector: '[appButton]',
  host: {
    'class': 'btn',
    '[class.btn-primary]': 'variant() === "primary"',
    '[class.btn-secondary]': 'variant() === "secondary"',
    '[class.btn-sm]': 'size() === "small"',
    '[class.btn-lg]': 'size() === "large"',
    '[class.disabled]': 'disabled()',
    '[attr.disabled]': 'disabled() || null',
  },
})
export class Button {
  variant = input<'primary' | 'secondary'>('primary');
  size = input<'small' | 'medium' | 'large'>('medium');
  disabled = input(false, { transform: booleanAttribute });
}

// Usage: <button appButton variant="primary" size="large">Click</button>
```

**Event Handling:**
```typescript
@Directive({
  selector: '[appClickOutside]',
  host: {
    '(document:click)': 'onDocumentClick($event)',
  },
})
export class ClickOutside {
  private el = inject(ElementRef<HTMLElement>);
  
  clickOutside = output<void>();
  
  onDocumentClick(event: MouseEvent) {
    if (!this.el.nativeElement.contains(event.target as Node)) {
      this.clickOutside.emit();
    }
  }
}

// Usage: <div appClickOutside (clickOutside)="closeMenu()">...</div>
```

**Keyboard Shortcuts:**
```typescript
@Directive({
  selector: '[appShortcut]',
  host: {
    '(document:keydown)': 'onKeydown($event)',
  },
})
export class Shortcut {
  key = input.required<string>({ alias: 'appShortcut' });
  ctrl = input(false, { transform: booleanAttribute });
  shift = input(false, { transform: booleanAttribute });
  alt = input(false, { transform: booleanAttribute });
  
  triggered = output<KeyboardEvent>();
  
  onKeydown(event: KeyboardEvent) {
    const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
    const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
    const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
    const altMatch = this.alt() ? event.altKey : !event.altKey;
    
    if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
      event.preventDefault();
      this.triggered.emit(event);
    }
  }
}

// Usage: <button appShortcut="s" ctrl (triggered)="save()">Save (Ctrl+S)</button>
```

**Structural Directives (Use for portals/overlays, NOT for control flow):**

Portal Directive:
```typescript
import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';

@Directive({
  selector: '[appPortal]',
})
export class Portal implements OnInit, OnDestroy {
  private templateRef = inject(TemplateRef<any>);
  private viewContainerRef = inject(ViewContainerRef);
  private viewRef: EmbeddedViewRef<any> | null = null;
  
  // Target container selector or element
  target = input<string | HTMLElement>('body', { alias: 'appPortal' });
  
  ngOnInit() {
    const container = this.getContainer();
    if (container) {
      this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
      this.viewRef.rootNodes.forEach(node => container.appendChild(node));
    }
  }
  
  ngOnDestroy() {
    this.viewRef?.destroy();
  }
  
  private getContainer(): HTMLElement | null {
    const target = this.target();
    if (typeof target === 'string') {
      return document.querySelector(target);
    }
    return target;
  }
}

// Usage: Render modal at body level
// <div *appPortal="'body'">
//   <div class="modal">Modal content</div>
// </div>
```

Lazy Render Directive:
```typescript
@Directive({
  selector: '[appLazyRender]',
})
export class LazyRender {
  private templateRef = inject(TemplateRef<any>);
  private viewContainer = inject(ViewContainerRef);
  private rendered = false;
  
  condition = input.required<boolean>({ alias: 'appLazyRender' });
  
  constructor() {
    effect(() => {
      // Only render once when condition becomes true
      if (this.condition() && !this.rendered) {
        this.viewContainer.createEmbeddedView(this.templateRef);
        this.rendered = true;
      }
    });
  }
}

// Usage: Render heavy component only when tab is first activated
// <div *appLazyRender="activeTab() === 'reports'">
//   <app-heavy-reports />
// </div>
```

Template Outlet with Context:
```typescript
interface TemplateContext<T> {
  $implicit: T;
  item: T;
  index: number;
}

@Directive({
  selector: '[appTemplateOutlet]',
})
export class TemplateOutlet<T> {
  private viewContainer = inject(ViewContainerRef);
  private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
  
  template = input.required<TemplateRef<TemplateContext<T>>>({ alias: 'appTemplateOutlet' });
  context = input.required<T>({ alias: 'appTemplateOutletContext' });
  index = input(0, { alias: 'appTemplateOutletIndex' });
  
  constructor() {
    effect(() => {
      const template = this.template();
      const context = this.context();
      const index = this.index();
      
      if (this.currentView) {
        this.currentView.context.$implicit = context;
        this.currentView.context.item = context;
        this.currentView.context.index = index;
        this.currentView.markForCheck();
      } else {
        this.currentView = this.viewContainer.createEmbeddedView(template, {
          $implicit: context,
          item: context,
          index,
        });
      }
    });
  }
}

// Usage: Custom list with template
// <ng-template #itemTemplate let-item let-i="index">
//   <div>{{ i }}: {{ item.name }}</div>
// </ng-template>
// <ng-container 
//   *appTemplateOutlet="itemTemplate; context: item; index: i"
// />
```

**Host Directives:**
```typescript
// Reusable behavior directives
@Directive({
  selector: '[focusable]',
  host: {
    'tabindex': '0',
    '(focus)': 'onFocus()',
    '(blur)': 'onBlur()',
    '[class.focused]': 'isFocused()',
  },
})
export class Focusable {
  isFocused = signal(false);
  
  onFocus() { this.isFocused.set(true); }
  onBlur() { this.isFocused.set(false); }
}

@Directive({
  selector: '[disableable]',
  host: {
    '[class.disabled]': 'disabled()',
    '[attr.aria-disabled]': 'disabled()',
  },
})
export class Disableable {
  disabled = input(false, { transform: booleanAttribute });
}

// Component using host directives
@Component({
  selector: 'app-custom-button',
  hostDirectives: [
    Focusable,
    {
      directive: Disableable,
      inputs: ['disabled'],
    },
  ],
  host: {
    'role': 'button',
    '(click)': 'onClick($event)',
    '(keydown.enter)': 'onClick($event)',
    '(keydown.space)': 'onClick($event)',
  },
  template: `<ng-content />`,
})
export class CustomButton {
  private disableable = inject(Disableable);
  
  clicked = output<void>();
  
  onClick(event: Event) {
    if (!this.disableable.disabled()) {
      this.clicked.emit();
    }
  }
}

// Usage: <app-custom-button disabled>Click me</app-custom-button>
```

Exposing Host Directive Outputs:
```typescript
@Directive({
  selector: '[hoverable]',
  host: {
    '(mouseenter)': 'onEnter()',
    '(mouseleave)': 'onLeave()',
    '[class.hovered]': 'isHovered()',
  },
})
export class Hoverable {
  isHovered = signal(false);
  
  hoverChange = output<boolean>();
  
  onEnter() {
    this.isHovered.set(true);
    this.hoverChange.emit(true);
  }
  
  onLeave() {
    this.isHovered.set(false);
    this.hoverChange.emit(false);
  }
}

@Component({
  selector: 'app-card',
  hostDirectives: [
    {
      directive: Hoverable,
      outputs: ['hoverChange'],
    },
  ],
  template: `<ng-content />`,
})
export class Card {}

// Usage: <app-card (hoverChange)="onHover($event)">...</app-card>
```

Directive Composition API:
```typescript
// Base directives
@Directive({ selector: '[withRipple]' })
export class Ripple {
  // Ripple effect implementation
}

@Directive({ selector: '[withElevation]' })
export class Elevation {
  elevation = input(2);
}

// Composed component
@Component({
  selector: 'app-material-button',
  hostDirectives: [
    Ripple,
    {
      directive: Elevation,
      inputs: ['elevation'],
    },
    {
      directive: Disableable,
      inputs: ['disabled'],
    },
  ],
  template: `<ng-content />`,
})
export class MaterialButton {}
```

## Advanced Patterns


Contains advanced patterns including:
- DOM Manipulation (Auto-Focus, Text Selection, Copy to Clipboard)
- Form Directives (Trim Input, Input Mask, Character Counter)
- Intersection Observer (Lazy Load, Infinite Scroll)
- Resize Observer
- Drag and Drop
- Permission Directive
- Export Directive Reference

---
