Files
NGRX-Playground/.agents/skills/angular-directives/SKILL.md
T
2026-03-08 08:51:02 +01:00

11 KiB

name, description
name description
angular-directives Create custom directives in Angular v20+ for DOM manipulation and behavior extension. Use for attribute directives that modify element behavior/appearance, structural directives for portals/overlays, and host directives for composition. Triggers on creating reusable DOM behaviors, extending element functionality, or composing behaviors across components. Note - use native @if/@for/@switch for control flow, not custom structural directives.

Angular Directives

Create custom directives for reusable DOM manipulation and behavior in Angular v20+.

Attribute Directives

Modify the appearance or behavior of an element:

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 host over @HostBinding/@HostListener:

@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

@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

@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

@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 structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native @if, @for, @switch.

Portal Directive

Render content in a different DOM location:

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

Defer rendering until condition is met (one-time):

@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

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

Compose directives on components or other directives:

// 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

@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

Combine multiple behaviors:

// 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 {}

For advanced patterns, see references/directive-patterns.md.