Перейти к содержимому

9. Пользовательские директивы

Кастомные директивы — это способ расширить HTML собственным поведением. Если встроенных директив Angular недостаточно, создай свою: изменяй стили, добавляй обработчики событий, управляй DOM — всё инкапсулировано и переиспользуемо.


ТипОписаниеПример использования
АтрибутнаяИзменяет внешний вид/поведение элементаappHighlight, appTooltip, appAutoResize
СтруктурнаяДобавляет/удаляет DOM-элементы*appUnless, *appRepeat, *appPermission

import {
Directive, ElementRef, Renderer2, HostListener, HostBinding, Input, OnInit
} from '@angular/core';
@Directive({
selector: '[appHighlight]', // Селектор в квадратных скобках = атрибут
standalone: true, // Angular 14+
})
export class HighlightDirective implements OnInit {
// @Input() получает значение атрибута
@Input('appHighlight') color = 'yellow'; // <div appHighlight="red">
@Input() highlightOpacity = 0.3;
// ElementRef — прямой доступ к DOM-элементу
// Renderer2 — безопасный способ изменять DOM (работает в SSR, Web Workers)
constructor(
private el: ElementRef<HTMLElement>,
private renderer: Renderer2
) {}
ngOnInit(): void {
// Рекомендуется через Renderer2, а не прямой доступ el.nativeElement.style
this.setBackground(this.color, this.highlightOpacity);
}
// @HostListener — слушает события на хост-элементе
@HostListener('mouseenter') onMouseEnter(): void {
this.setBackground(this.color, this.highlightOpacity);
}
@HostListener('mouseleave') onMouseLeave(): void {
this.setBackground('transparent', 0);
}
// @HostBinding — привязывает свойства/атрибуты хост-элемента
@HostBinding('style.cursor') cursor = 'pointer';
@HostBinding('attr.title') title = 'Наведи на меня!';
private setBackground(color: string, opacity: number): void {
this.renderer.setStyle(
this.el.nativeElement,
'background-color',
\`\${color}\`
);
this.renderer.setStyle(
this.el.nativeElement,
'opacity',
String(opacity + 0.7)
);
}
}
<!-- Использование -->
<p appHighlight>Подсветится жёлтым при наведении</p>
<p appHighlight="red">Подсветится красным</p>
<p [appHighlight]="selectedColor" [highlightOpacity]="0.5">Динамический цвет</p>

@Directive({
selector: '[appClickOutside]',
standalone: true,
})
export class ClickOutsideDirective {
@Output() clickOutside = new EventEmitter<void>();
// Слушаем клик на document (не на элементе)
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as Node;
const isInside = this.el.nativeElement.contains(target);
if (!isInside) {
this.clickOutside.emit();
}
}
// Слушаем клавиатуру
@HostListener('document:keydown.escape')
onEscape(): void {
this.clickOutside.emit();
}
// Слушаем события на самом элементе
@HostListener('focus') onFocus(): void {
this.renderer.addClass(this.el.nativeElement, 'focused');
}
@HostListener('blur') onBlur(): void {
this.renderer.removeClass(this.el.nativeElement, 'focused');
}
constructor(
private el: ElementRef,
private renderer: Renderer2
) {}
}
<!-- Компонент dropdown закрывается при клике снаружи -->
<div appClickOutside (clickOutside)="isOpen = false">
<button (click)="isOpen = !isOpen">Открыть</button>
<ul *ngIf="isOpen">
<li>Пункт 1</li>
<li>Пункт 2</li>
</ul>
</div>

🔗 @HostBinding — привязываем свойства хост-элемента

Заголовок раздела «🔗 @HostBinding — привязываем свойства хост-элемента»
@Directive({
selector: '[appButtonLoading]',
standalone: true,
})
export class ButtonLoadingDirective {
@Input() set appButtonLoading(loading: boolean) {
this.isLoading = loading;
}
isLoading = false;
// Напрямую управляем атрибутами/классами/стилями хост-элемента
@HostBinding('disabled')
get isDisabled(): boolean { return this.isLoading; }
@HostBinding('class.loading')
get hasLoadingClass(): boolean { return this.isLoading; }
@HostBinding('attr.aria-busy')
get ariaBusy(): string { return this.isLoading ? 'true' : 'false'; }
@HostBinding('style.opacity')
get opacity(): number { return this.isLoading ? 0.7 : 1; }
@HostBinding('style.cursor')
get cursor(): string { return this.isLoading ? 'wait' : 'pointer'; }
}
<button [appButtonLoading]="isSubmitting" (click)="submit()">
{{ isSubmitting ? 'Загрузка...' : 'Отправить' }}
</button>

@Directive({
selector: '[appAutoResize]',
standalone: true,
})
export class AutoResizeDirective implements OnInit {
constructor(
private el: ElementRef<HTMLTextAreaElement>,
private renderer: Renderer2
) {}
ngOnInit(): void {
// ElementRef.nativeElement — прямой доступ к DOM-узлу
// Используй только для чтения! Для записи — Renderer2
this.resize();
}
@HostListener('input') onInput(): void {
this.resize();
}
private resize(): void {
const textarea = this.el.nativeElement;
// Renderer2 методы — работают в SSR и Web Workers
this.renderer.setStyle(textarea, 'height', 'auto');
this.renderer.setStyle(textarea, 'height', textarea.scrollHeight + 'px');
this.renderer.setStyle(textarea, 'overflow', 'hidden');
}
}
// Renderer2 API — основные методы:
// renderer.setStyle(el, 'color', 'red') — установить CSS
// renderer.removeStyle(el, 'color') — удалить CSS
// renderer.addClass(el, 'active') — добавить класс
// renderer.removeClass(el, 'active') — удалить класс
// renderer.setAttribute(el, 'aria-label', '...')— установить атрибут
// renderer.removeAttribute(el, 'disabled') — удалить атрибут
// renderer.appendChild(parent, child) — добавить дочерний элемент
// renderer.createElement('div') — создать элемент
// renderer.listen(el, 'click', handler) — добавить слушатель событий

Структурные директивы работают с TemplateRef и ViewContainerRef:

app-unless.directive.ts
import {
Directive, Input, TemplateRef, ViewContainerRef
} from '@angular/core';
@Directive({
selector: '[appUnless]',
standalone: true,
})
export class UnlessDirective {
private hasView = false;
// TemplateRef — ссылка на <ng-template> (то что прячется за *)
// ViewContainerRef — место в DOM куда вставляется шаблон
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef
) {}
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
// Создаём View из шаблона и вставляем в DOM
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
// Очищаем ViewContainer — удаляем из DOM
this.viewContainer.clear();
this.hasView = false;
}
}
}
<!-- *appUnless — противоположность *ngIf -->
<div *appUnless="isLoggedIn">
Войди, чтобы увидеть контент
</div>
<!-- Angular разворачивает * в ng-template: -->
<ng-template [appUnless]="isLoggedIn">
<div>Войди, чтобы увидеть контент</div>
</ng-template>

app-repeat.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
interface RepeatContext {
$implicit: number; // Текущий индекс (let i = implicit)
index: number;
count: number;
first: boolean;
last: boolean;
even: boolean;
odd: boolean;
}
@Directive({
selector: '[appRepeat]',
standalone: true,
})
export class RepeatDirective {
@Input() set appRepeat(count: number) {
this.viewContainer.clear();
for (let i = 0; i < count; i++) {
const context: RepeatContext = {
$implicit: i,
index: i,
count,
first: i === 0,
last: i === count - 1,
even: i % 2 === 0,
odd: i % 2 !== 0,
};
this.viewContainer.createEmbeddedView(this.templateRef, context);
}
}
constructor(
private templateRef: TemplateRef<RepeatContext>,
private viewContainer: ViewContainerRef
) {}
}
<!-- Повторить шаблон N раз — аналог range() в Python -->
<div *appRepeat="5; let i; let first=first; let last=last">
Элемент {{ i + 1 }}
<span *ngIf="first">← первый</span>
<span *ngIf="last">← последний</span>
</div>
<!-- Звёздочки рейтинга через appRepeat -->
<span *appRepeat="rating; let i">★</span>
<span *appRepeat="5 - rating; let i">☆</span>

app-permission.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[appPermission]',
standalone: true,
})
export class PermissionDirective {
@Input() set appPermission(permission: string | string[]) {
const permissions = Array.isArray(permission) ? permission : [permission];
const hasAccess = permissions.some(p => this.auth.hasPermission(p));
this.viewContainer.clear();
if (hasAccess) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private auth: AuthService
) {}
}
<!-- Показывать только для admins -->
<button *appPermission="'admin'">Удалить</button>
<!-- Для нескольких ролей -->
<div *appPermission="['admin', 'editor']">
Редактировать
</div>

tooltip.directive.ts
import {
Directive, Input, HostListener, ElementRef, Renderer2, OnDestroy
} from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true,
})
export class TooltipDirective implements OnDestroy {
@Input('appTooltip') text = '';
@Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';
private tooltipEl: HTMLElement | null = null;
constructor(
private el: ElementRef<HTMLElement>,
private renderer: Renderer2
) {}
@HostListener('mouseenter') show(): void {
if (!this.text) return;
this.tooltipEl = this.renderer.createElement('div');
const textNode = this.renderer.createText(this.text);
this.renderer.appendChild(this.tooltipEl, textNode);
this.renderer.setStyle(this.tooltipEl, 'position', 'fixed');
this.renderer.setStyle(this.tooltipEl, 'background', '#1e293b');
this.renderer.setStyle(this.tooltipEl, 'color', 'white');
this.renderer.setStyle(this.tooltipEl, 'padding', '6px 10px');
this.renderer.setStyle(this.tooltipEl, 'border-radius', '6px');
this.renderer.setStyle(this.tooltipEl, 'font-size', '12px');
this.renderer.setStyle(this.tooltipEl, 'z-index', '9999');
this.renderer.setStyle(this.tooltipEl, 'pointer-events', 'none');
this.renderer.appendChild(document.body, this.tooltipEl);
const rect = this.el.nativeElement.getBoundingClientRect();
const tipRect = this.tooltipEl.getBoundingClientRect();
const positions = {
top: { top: rect.top - tipRect.height - 8, left: rect.left + rect.width / 2 - tipRect.width / 2 },
bottom: { top: rect.bottom + 8, left: rect.left + rect.width / 2 - tipRect.width / 2 },
left: { top: rect.top + rect.height / 2 - tipRect.height / 2, left: rect.left - tipRect.width - 8 },
right: { top: rect.top + rect.height / 2 - tipRect.height / 2, left: rect.right + 8 },
};
const pos = positions[this.tooltipPosition];
this.renderer.setStyle(this.tooltipEl, 'top', pos.top + 'px');
this.renderer.setStyle(this.tooltipEl, 'left', pos.left + 'px');
}
@HostListener('mouseleave') hide(): void {
if (this.tooltipEl) {
this.renderer.removeChild(document.body, this.tooltipEl);
this.tooltipEl = null;
}
}
ngOnDestroy(): void {
this.hide();
}
}
<button appTooltip="Нажми чтобы сохранить" tooltipPosition="top">
💾 Сохранить
</button>
<span [appTooltip]="helpText" tooltipPosition="right">ℹ️</span>

// Объявление standalone директивы
@Directive({
selector: '[appHighlight]',
standalone: true, // Без NgModule!
})
export class HighlightDirective { /* ... */ }
// Использование в standalone компоненте
@Component({
standalone: true,
imports: [HighlightDirective], // ← Просто импортируем
template: `<p appHighlight="blue">Текст</p>`
})
export class ArticleComponent {}
// Удобный barrel export
// directives/index.ts
export * from './highlight.directive';
export * from './tooltip.directive';
export * from './unless.directive';
export * from './repeat.directive';
export * from './click-outside.directive';

ElementRef.nativeElementRenderer2
Прямой доступ к DOM❌ (абстракция)
Работает в SSR⚠️ Нет✅ Да
Работает в Web Workers❌ Нет✅ Да
Безопасность от XSS⚠️ Ручная✅ Встроенная
Рекомендуется дляТолько чтениеИзменение DOM