Angularのディレクティブとは、既存の HTML 要素やコンポーネントに「ふるまい」を後付けするクラスです。 @Directive({ selector: '[appHighlight]' }) と書いたクラスを用意すると、テンプレートで appHighlight という属性を付けた要素に、色を変える・入力を制限する・クリックを検知するといった処理を足せます。

この記事では、属性ディレクティブを host プロパティで書く方法を中心に、@HostBinding / @HostListener との対応表、そのまま貼って使える4つのディレクティブ(数値のみ入力・オートフォーカス・外側クリック検知・権限による表示切替)、ngClass / ngStyle と [class.x] / [style.x] の書き方、構造ディレクティブ、hostDirectives での合成、テストまでを扱います。コードと出力は、すべて下の環境で動かしたものです。

確認環境:Ubuntu 24.04 / Node.js 24.19.0 / Angular 22.2.1(@angular/cli 22.2.1 の ng new で作成)/ TypeScript 6.0.3 / Vitest 5.0.3(ng test、jsdom)/ Chromium 141(Playwright 1.56.0)

ディレクティブの早見表

やりたいこと 書き方
属性ディレクティブを作る @Directive({ selector: '[appXxx]' }) を付けたクラスを作る
使う コンポーネントの imports に追加し、要素に appXxx と書く
要素にクラスを付け外しする host: { '[class.active]': 'active()' }
要素のスタイルを変える host: { '[style.backgroundColor]': 'color()' }
要素に属性を付ける(固定値) host: { role: 'button' }
要素に属性を付ける(値が変わる) host: { '[attr.aria-pressed]': 'pressed()' }
要素のイベントを受け取る host: { '(click)': 'onClick($event)' }
文書全体・ウィンドウのイベントを受け取る host: { '(document:click)': '...', '(window:resize)': '...' }
値を受け取る readonly appXxx = input('')(セレクターと同じ名前にすると appXxx="値" と書ける)
親へ知らせる readonly changed = output()
要素そのものを触る inject(ElementRef).nativeElement
表示する・しないを切り替える(構造ディレクティブ) inject(TemplateRef) と inject(ViewContainerRef) を使い、*appXxx で付ける
ディレクティブを合成する hostDirectives: [A, B]

この記事で作るのは、要素の見た目やふるまいを変える属性ディレクティブ(appHighlight など)と、要素を追加・削除する構造ディレクティブ(appHasRole など)です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularディレクティブの作り方|属性ディレクティブとhost」

最小の属性ディレクティブを作る

マウスが乗っているあいだ背景色を付けるディレクティブです。Angular 22.2.1 の ng new で作ったプロジェクトでは、ディレクティブは既定でスタンドアロン(単体で imports に追加できる形)なので、standalone: true を書く必要はありませんでした。

// src/app/directives/highlight.directive.ts
import { Directive, input, signal } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  host: {
    '[style.backgroundColor]': 'active() ? (appHighlight() || defaultColor()) : null',
    '[class.is-highlighted]': 'active()',
    '(mouseenter)': 'active.set(true)',
    '(mouseleave)': 'active.set(false)',
  },
})
export class HighlightDirective {
  /** セレクターと同じ名前の入力。appHighlight="yellow" と書ける */
  readonly appHighlight = input('');
  readonly defaultColor = input('lightyellow');
  protected readonly active = signal(false);
}

使う側は imports に追加して、要素に属性として書きます。

@Component({
  imports: [HighlightDirective],
  template: `<p id="a" appHighlight="lightblue">host</p><p id="c" appHighlight>default</p>`,
})
class Host {}

マウスを乗せる前・乗せたとき・離したときの要素の HTML です(1つ目の要素)。色を指定しなかった2つ目の要素は、既定の lightyellow になりました。

<!-- 初期 -->
<p id="a" apphighlight="lightblue">host</p>
<!-- mouseenter -->
<p id="a" apphighlight="lightblue" style="background-color: lightblue;" class="is-highlighted">host</p>
<!-- mouseleave -->
<p id="a" apphighlight="lightblue" style="" class="">host</p>
<!-- 色の指定なしで mouseenter -->
<p id="c" apphighlight="" style="background-color: lightyellow;" class="is-highlighted">default</p>

selector の角かっこは「この属性が付いた要素」を表す CSS セレクターで、host にはディレクティブが付いた要素(ホスト要素)へのバインディングとイベントを書きます。

要素を直接書き換える処理(el.style.backgroundColor = ...)を書かなくても、host に式を書くだけで見た目を切り替えられます。 公式ガイドのハイライトの例は ElementRef で要素のスタイルを直接書き換える形ですが、クラスやスタイルの切り替えだけなら host のバインディングで足ります。

host プロパティと @HostBinding / @HostListener の対応表

ホスト要素へのバインディングには、@Directive の host に書く方法と、クラスのメンバーに @HostBinding / @HostListener を付ける方法があります。

やりたいこと host プロパティ デコレーター
固定の属性 role: 'button' @HostBinding('attr.role') role = 'button'
属性をバインド '[attr.aria-pressed]': 'pressed()' @HostBinding('attr.aria-pressed') pressed = false
クラスの付け外し '[class.is-pressed]': 'pressed()' @HostBinding('class.is-pressed') pressed = false
スタイル '[style.opacity]': 'pressed() ? 0.6 : null' @HostBinding('style.opacity') get opacity() { ... }
要素のイベント '(click)': 'toggle($event)' @HostListener('click', ['$event'])
キーを絞ったイベント '(keydown.enter)': 'toggle($event)' @HostListener('keydown.enter', ['$event'])
文書全体のイベント '(document:keydown.escape)': 'pressed.set(false)' @HostListener('document:keydown.escape')
ウィンドウのイベント '(window:resize)': '...' @HostListener('window:resize')

表の内容を、同じ動きをする2つのディレクティブとして書いたものです。

// src/app/directives/host-pairs.ts
import { Directive, HostBinding, HostListener, signal } from '@angular/core';

/** host プロパティで書いた形 */
@Directive({
  selector: '[appPairsHost]',
  host: {
    role: 'button',
    '[attr.aria-pressed]': 'pressed()',
    '[class.is-pressed]': 'pressed()',
    '[style.opacity]': 'pressed() ? 0.6 : null',
    '(click)': 'toggle($event)',
    '(keydown.enter)': 'toggle($event)',
    '(document:keydown.escape)': 'pressed.set(false)',
    '(window:resize)': 'log.push("resize")',
  },
})
export class PairsHostDirective {
  readonly pressed = signal(false);
  readonly log: string[] = [];
  toggle(event: Event): void {
    this.log.push(event.type);
    this.pressed.update((v) => !v);
  }
}

/** 同じ動きをデコレーターで書いた形 */
@Directive({ selector: '[appPairsDecorator]' })
export class PairsDecoratorDirective {
  readonly log: string[] = [];

  @HostBinding('attr.role') role = 'button';
  @HostBinding('attr.aria-pressed') @HostBinding('class.is-pressed') pressed = false;
  @HostBinding('style.opacity') get opacity(): number | null {
    return this.pressed ? 0.6 : null;
  }

  @HostListener('click', ['$event'])
  @HostListener('keydown.enter', ['$event'])
  toggle(event: Event): void {
    this.log.push(event.type);
    this.pressed = !this.pressed;
  }

  @HostListener('document:keydown.escape')
  release(): void {
    this.pressed = false;
  }

  @HostListener('window:resize')
  onResize(): void {
    this.log.push('resize');
  }
}

2つを並べて、クリック → Enter → Enter → 文書全体への Esc の順に操作したときの HTML です(左が host、右がデコレーター。2回目の Enter は click のときと同じ表示だったので省いています)。

初期           <span role="button" apppairshost="" aria-pressed="false">A</span> / <span apppairsdecorator="" role="button" aria-pressed="false">B</span>
click          <span role="button" apppairshost="" aria-pressed="true" style="opacity: 0.6;" class="is-pressed">A</span> / <span apppairsdecorator="" role="button" aria-pressed="true" style="opacity: 0.6;" class="is-pressed">B</span>
Enter          <span role="button" apppairshost="" aria-pressed="false" style="" class="">A</span> / <span apppairsdecorator="" role="button" aria-pressed="false" style="" class="">B</span>
document Esc   <span role="button" apppairshost="" aria-pressed="false" style="" class="">A</span> / <span apppairsdecorator="" role="button" aria-pressed="false" style="" class="">B</span>
log = click,keydown,keydown,resize / click,keydown,keydown,resize

どちらの書き方でも、属性・クラス・スタイル・イベントの結果は同じでした。

どちらで書くか

観点 host プロパティ デコレーター
公式の位置づけ 推奨 後方互換のために残されている
書く場所 @Directive の中に1か所にまとまる クラスの各メンバーに分かれる
書き間違いの検出 ビルド時に型チェックされた(下のエラー) TypeScript のメンバーなので型チェックされる

公式の API ドキュメント(HostBinding)には、常に host プロパティを優先して使うこと、このデコレーターは後方互換のためだけにあることが書かれています。新しく書くディレクティブは host にまとめ、既存のデコレーターは上の対応表で読み替える、という進め方がわかりやすいです。

host の中身は文字列ですが、22.2.1 の ng build では式の中身まで型チェックされました。メンバー名を打ち間違えたときのエラーです。

✘ [ERROR] TS2339: Property 'actve' does not exist on type 'E3'. [plugin angular-compiler]
      25 │   host: { '[class.on]': 'actve()', '(click)': 'toggle()' },
         ╵                          ~~~~~
✘ [ERROR] TS2339: Property 'toggle' does not exist on type 'E3'. [plugin angular-compiler]

コピペで使える4つのディレクティブ

1. 数値のみ入力

数字以外の文字を取り除くディレクティブです。キー入力だけでなく、貼り付けや全角数字にも対応し、フォームの値もそろえます。

// src/app/directives/numeric-only.directive.ts
import { Directive, ElementRef, inject } from '@angular/core';

/** 数字以外を取り除く。貼り付けや日本語入力の確定にも対応 */
@Directive({
  selector: 'input[appNumericOnly]',
  host: {
    inputmode: 'numeric',
    '(input)': 'sanitize($event)',
    '(compositionend)': 'sanitize($event)',
  },
})
export class NumericOnlyDirective {
  private readonly el = inject<ElementRef<HTMLInputElement>>(ElementRef).nativeElement;

  protected sanitize(event: Event): void {
    // 日本語入力の変換中は触らない(確定後の compositionend で処理する)
    if ((event as InputEvent).isComposing) return;
    const half = this.el.value.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0));
    const clean = half.replace(/\D/g, '');
    if (clean !== this.el.value) {
      this.el.value = clean;
      // フォーム(ngModel / formControl)にも整えた値を伝える
      this.el.dispatchEvent(new Event('input', { bubbles: true }));
    }
  }
}

FormControl と組み合わせて、いくつかの文字列を入力したときの結果です。

inputmode = numeric
入力 "12a3"     → 表示 "123"   / FormControl "123"
入力 "1,280円"   → 表示 "1280"  / FormControl "1280"
入力 "-5.5"     → 表示 "55"    / FormControl "55"
入力 "456"      → 表示 "456"   / FormControl "456"
入力 "abc"      → 表示 ""      / FormControl ""

ng build したアプリを Chromium で開き、[(ngModel)] を付けた入力欄にキーボードで 12a3-4円 と打ったとき、続けて 税込 1,980 円 をまとめて挿入したときの結果です。

qty 表示 = 1234 / model = 1234
貼り付け相当 = 1980 / model = 1980
ポイント 理由
セレクターを input[appNumericOnly] にする 対象を入力欄に絞り、ほかの要素では動かないようにする
inputmode: 'numeric' を付ける スマートフォンで数字のキーボードを出すための属性
isComposing のあいだは処理しない 日本語入力の変換中に値を書き換えないため。確定時の compositionend で処理する
値を直したあと input イベントを送り直す ngModel や formControl に、整えたあとの値を伝えるため

小数やマイナスも受け付けたい場合は、取り除く対象の正規表現(/\D/g)を用途に合わせて変えます。

2. オートフォーカス

画面やダイアログが表示されたときに、最初の入力欄へフォーカスを当てます。

// src/app/directives/autofocus.directive.ts
import { Directive, ElementRef, afterNextRender, booleanAttribute, inject, input } from '@angular/core';

/** 表示されたときにフォーカスを当てる。[appAutofocus]="false" で止められる */
@Directive({ selector: '[appAutofocus]' })
export class AutofocusDirective {
  readonly appAutofocus = input(true, { transform: booleanAttribute });
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;

  constructor() {
    // 描画が終わってから 1 回だけ実行される(サーバー側の描画では動かない)
    afterNextRender(() => {
      if (this.appAutofocus()) this.el.focus();
    });
  }
}

3つの入力欄のうち、2つ目に appAutofocus、3つ目に [appAutofocus]="false" を付けたときの、フォーカスが当たっている要素です。Chromium で開いたアプリでも、ディレクティブを付けた入力欄(id は qty)にフォーカスが当たっていました。

activeElement = second
focus = qty

HTML 標準の autofocus 属性はページを読み込んだときに働くものです。@if で後から表示される入力欄にもフォーカスを当てたいときに、このディレクティブが役立ちます。afterNextRender を使うと、要素が画面に描画されたあとで1回だけ処理を実行できます。

3. 外側クリック検知

メニューやポップアップの外側がクリックされたら閉じる、という処理に使います。

// src/app/directives/click-outside.directive.ts
import { Directive, ElementRef, inject, output } from '@angular/core';

/** 要素の外側がクリックされたら知らせる(メニューやポップアップを閉じる用途) */
@Directive({
  selector: '[appClickOutside]',
  host: { '(document:click)': 'onDocumentClick($event)' },
})
export class ClickOutsideDirective {
  readonly appClickOutside = output<MouseEvent>();
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;

  protected onDocumentClick(event: MouseEvent): void {
    if (!this.el.contains(event.target as Node)) {
      this.appClickOutside.emit(event);
    }
  }
}

使う側のテンプレートです。出力(output)の名前をセレクターと同じにしているので、(appClickOutside) と書くだけでディレクティブが付きます。

<button id="outside">外</button>
@if (open()) {
  <div id="menu" (appClickOutside)="open.set(false)"><button id="inside">内</button></div>
}
初期            : menu = true
内側をクリック  : menu = true
外側をクリック  : menu = false

Chromium でも、メニューの内側をクリックしたあとは表示されたまま、外側の要素にマウスを乗せただけでも表示されたまま、外側をクリックすると消えることを確かめました。

menu(初期) = 1
menu(内側クリック後) = 1
menu(外側の要素に hover しただけ) = 1
menu(外側クリック後) = 0

'(document:click)' のリスナーは、ディレクティブが破棄されると Angular が自動で解除します。 document への登録と解除の回数を数えたところ、メニューが消えた時点で解除が1回呼ばれ、その後のクリックでは通知されませんでした。

表示中: add(click) = 1 / remove(click) = 0 / 通知 = 1
破棄後: add(click) = 1 / remove(click) = 1 / 通知 = 1

4. 権限による表示切替

「管理者にだけ削除ボタンを見せる」のように、権限に応じて要素そのものを出し分けます。これは要素を追加・削除するので、構造ディレクティブとして作ります。

// src/app/directives/has-role.directive.ts
import { Directive, Injectable, TemplateRef, ViewContainerRef, effect, inject, input, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CurrentUser {
  readonly roles = signal<string[]>([]);
}

/** 権限があるときだけ中身を描画する。*appHasRole="'admin'; else denied" */
@Directive({ selector: '[appHasRole]' })
export class HasRoleDirective {
  readonly appHasRole = input.required<string | string[]>();
  readonly appHasRoleElse = input<TemplateRef<unknown> | null>(null);

  private readonly templateRef = inject(TemplateRef<unknown>);
  private readonly viewContainer = inject(ViewContainerRef);
  private readonly user = inject(CurrentUser);

  constructor() {
    effect(() => {
      const required = [this.appHasRole()].flat();
      const allowed = required.some((role) => this.user.roles().includes(role));
      const elseTpl = this.appHasRoleElse();
      this.viewContainer.clear();
      if (allowed) {
        this.viewContainer.createEmbeddedView(this.templateRef);
      } else if (elseTpl) {
        this.viewContainer.createEmbeddedView(elseTpl);
      }
    });
  }
}

使う側のテンプレートと、権限を切り替えたときに描画された HTML です。

<button *appHasRole="'admin'; else denied">削除</button>
<ng-template #denied><span>権限がありません</span></ng-template>
<a *appHasRole="['admin', 'editor']">編集</a>
roles=[]           <span>権限がありません</span>
roles=['editor']   <span>権限がありません</span><a>編集</a>
roles=['admin']    <button>削除</button><a>編集</a>
ポイント 内容
権限がないときは要素が DOM に存在しない CSS で隠すのと違い、ボタンそのものが作られない
effect の中で signal を読む 権限(roles)が変わると自動で描画し直される
配列も受け取れる [this.appHasRole()].flat() で、1つでも複数でも同じ扱いにしている
else のテンプレート appHasRoleElse という入力で受け取る(次の章で説明)

画面での出し分けは、利用者にとっての見やすさのための仕組みです。データを守る判定は、API を提供するサーバー側でも必ず行います。

ngClass / ngStyle と [class.x] / [style.x] の書き方早見表

クラスとスタイルの切り替えは、ディレクティブを作らなくてもテンプレートのバインディングで書けます。on = true、color = 'red'、w = 120 のときに描画された結果です。

クラス

書き方 描画された属性 使いどころ
[class.active]="on" class="active" 1つのクラスの付け外し
class="base" [class]="'a b'" class="base a b" 文字列でまとめて指定
class="base" [class]="{ a: on, b: !on }" class="base a" 条件ごとに付け外し
class="base" [class]="['a', 'b']" class="base a b" 配列で指定
class="base" [ngClass]="{ 'a b': on, c: !on }" class="base a b" 1つの条件で複数のクラス
class="base" [ngClass]="['a', 'b']" class="base a b" 配列で指定
[class.active]="on" [class]="{ active: false }" class="active" 両方あるときは [class.active] の指定が残った

スタイルと属性

書き方 描画された属性 使いどころ
[style.color]="color" style="color: red;" 1つのプロパティ
[style.width.px]="w" style="width: 120px;" 単位付き(数値を渡す)
[style.width.%]="50" style="width: 50%;" 単位付き
[style]="{ color: color, 'font-size': '12px' }" style="color: red; font-size: 12px;" オブジェクトでまとめて指定
[style]="'color: red; margin: 0'" style="color: red; margin: 0px;" 文字列でまとめて指定
[ngStyle]="{ 'width.px': w, color: color }" style="width: 120px; color: red;" キーに単位を書ける
[style.--gap]="'8px'" style="--gap: 8px;" CSS カスタムプロパティ
[style.color]="null" (style 属性なし) null で指定を外す
[attr.aria-label]="label" [attr.data-id]="null" aria-label="閉じる" null を渡した属性は付かない

どれを選ぶか

状況 選ぶもの
クラスやスタイルを1つだけ切り替える [class.x] / [style.x]
複数をまとめて切り替える [class]="{...}" / [style]="{...}"(imports への追加が不要)
1つの条件で複数のクラスをまとめて付けたい、キーに単位を書きたい [ngClass] / [ngStyle](imports に NgClass / NgStyle を追加)
同じ切り替えを何か所でも使う、イベントも絡む 属性ディレクティブにまとめる

[class.x] と [style.x] は Angular のテンプレートの標準機能なので、追加の imports なしで使えます。 ngClass と ngStyle は @angular/common のディレクティブで、既存のコードでも広く使われています。

構造ディレクティブのしくみ(TemplateRef と ViewContainerRef)

アスタリスクは ng-template の省略記法

*appHasRole のアスタリスクは、要素を ng-template で包む書き方の省略形です。公式ガイドには、Angular は構造ディレクティブの前のアスタリスクを、ディレクティブをホストし、要素とその子孫を囲む ng-template に変換する、と書かれています。次の2つは同じ意味で、どちらも同じ結果になることを確かめました。

<!-- 省略記法 -->
<button *appHasRole="'admin'; else denied">削除</button>

<!-- 省略しない書き方 -->
<ng-template [appHasRole]="'admin'" [appHasRoleElse]="denied">
  <button>削除</button>
</ng-template>
roles=[]        <span>権限がありません</span>
roles=['admin'] <button>削除</button>
省略記法の部分 変換後 ルール
*appHasRole="'admin'" [appHasRole]="'admin'" 最初の式はセレクターと同じ名前の入力へ
; else denied [appHasRoleElse]="denied" キーは「セレクター名 + 先頭を大文字にしたキー」の入力へ
; let n コンテキストの $implicit を n で受ける 名前を指定しない let は $implicit
; let i = index コンテキストの index を i で受ける let 変数 = コンテキストのキー

ディレクティブ側は、TemplateRef(包まれた中身の型紙)と ViewContainerRef(描画する場所)を受け取り、createEmbeddedView() で描画、clear() で取り除く、という2つの操作を組み合わせます。

コンテキストを渡す(繰り返しの例)

createEmbeddedView() の第2引数にオブジェクトを渡すと、テンプレートの中で let を使って受け取れます。指定した回数だけ中身を繰り返す例です。

// src/app/directives/repeat.directive.ts
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';

interface RepeatContext {
  $implicit: number; // let n で受け取る値(1 始まり)
  index: number;     // let i = index
  last: boolean;     // let isLast = last
}

/** 指定した回数だけ中身を繰り返す。*appRepeat="3; let n; let i = index" */
@Directive({ selector: '[appRepeat]' })
export class RepeatDirective {
  readonly appRepeat = input.required<number>();
  private readonly templateRef = inject<TemplateRef<RepeatContext>>(TemplateRef);
  private readonly viewContainer = inject(ViewContainerRef);

  constructor() {
    effect(() => {
      const count = this.appRepeat();
      this.viewContainer.clear();
      for (let i = 0; i < count; i++) {
        this.viewContainer.createEmbeddedView(this.templateRef, { $implicit: i + 1, index: i, last: i === count - 1 });
      }
    });
  }

  /** テンプレート内の変数に型を付ける */
  static ngTemplateContextGuard(dir: RepeatDirective, ctx: unknown): ctx is RepeatContext {
    return true;
  }
}
<ul>
  <li *appRepeat="count(); let n; let i = index; let isLast = last">{{ n }}番目(index={{ i }}){{ isLast ? ' 最後' : '' }}</li>
</ul>
count=3: 1番目(index=0) | 2番目(index=1) | 3番目(index=2) 最後
count=1: 1番目(index=0) 最後

ngTemplateContextGuard は、テンプレートの中の n や i に型を付けるための静的メソッドです。公式ガイドでは、テンプレートの型チェックがコンテキストの型を判断するために使う、と説明されています。

アスタリスクを付け忘れたときのエラー

構造ディレクティブを [appHasRole]="'admin'" のように通常の属性として書くと、実行時に次のエラーになりました。

NG0201: No provider for TemplateRef found. Find more at https://v22.angular.dev/errors/NG0201

TemplateRef は ng-template の上でだけ受け取れます。このエラーが出たら、アスタリスクを付けるか、ng-template に付ける形に直します。 また、公式ガイドには、省略記法では1つの要素に構造ディレクティブを1つしか付けられないこと、複数必要なときは ng-container で包んで階層を分けることが書かれています。

hostDirectives でディレクティブを合成する

hostDirectives を使うと、既存のディレクティブを別のコンポーネントやディレクティブに「最初から付いた状態」で組み込めます。使う側のテンプレートに属性をいくつも並べる必要がなくなります。

// src/app/directives/composed.ts
import { Component, Directive, input, output, signal } from '@angular/core';
import { HighlightDirective } from './highlight.directive';

/** ホバー中だけ title 属性に説明を出す小さなディレクティブ */
@Directive({
  selector: '[appHint]',
  host: {
    '[attr.title]': 'appHint()',
    '(focus)': 'shown.emit(appHint())',
  },
})
export class HintDirective {
  readonly appHint = input('');
  readonly shown = output<string>();
}

/** 2 つのディレクティブを合成したボタン。使う側は app-fancy-button と書くだけ */
@Component({
  selector: 'app-fancy-button',
  template: `<ng-content />`,
  host: { role: 'button', tabindex: '0' },
  hostDirectives: [
    { directive: HighlightDirective, inputs: ['appHighlight: color'] },
    { directive: HintDirective, inputs: ['appHint: hint'], outputs: ['shown: hintShown'] },
  ],
})
export class FancyButton {}

/** ディレクティブ同士の合成もできる */
@Directive({
  selector: '[appCard]',
  host: { '[class.card]': 'true' },
  hostDirectives: [HighlightDirective],
})
export class CardDirective {
  protected readonly opened = signal(false);
}

使う側のテンプレートと、マウスを乗せる前後の HTML です。

<app-fancy-button color="gold" hint="保存します" (hintShown)="log.push($event)">保存</app-fancy-button>
<section appCard>card</section>
初期      : <app-fancy-button role="button" tabindex="0" color="gold" hint="保存します" title="保存します">保存</app-fancy-button>
mouseenter: <app-fancy-button role="button" tabindex="0" color="gold" hint="保存します" title="保存します" style="background-color: gold;" class="is-highlighted">保存</app-fancy-button>
hintShown : ["保存します"]
appCard   : <section appcard="" class="card is-highlighted" style="background-color: lightyellow;">card</section>

テンプレートには appHighlight も appHint も書いていませんが、両方のふるまいが app-fancy-button に付いています。

ルール 内容
入力・出力は既定では公開されない inputs / outputs に書いたものだけが使う側から見える
別名を付けられる 'appHighlight: color' と書くと、使う側は color="gold" と書ける
ディレクティブ同士でも合成できる CardDirective は HighlightDirective を組み込んでいる
実行の順番 組み込まれた側のコンストラクター・ライフサイクル・バインディングが先に動く
実行時には足せない ビルド時に静的に決まる

表の内容は、公式ガイド(Directive composition API)の説明に沿っています。同じガイドには、hostDirectives に入れるディレクティブは standalone: false を指定できないことも書かれています。

ディレクティブのテスト

ディレクティブは、テスト用の小さなコンポーネントに付けて確かめます。By.directive() を使うと、ディレクティブが付いた要素だけを集められます。

// src/app/directives/article.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { describe, expect, it } from 'vitest';
import { CurrentUser, HasRoleDirective } from './has-role.directive';
import { HighlightDirective } from './highlight.directive';
import { NumericOnlyDirective } from './numeric-only.directive';

describe('HighlightDirective', () => {
  @Component({
    imports: [HighlightDirective],
    template: `<p appHighlight="lightblue">A</p><p appHighlight>B</p><p>C</p>`,
  })
  class Host {}

  it('マウスが乗ったら色が付き、離れたら戻る', async () => {
    const fixture = TestBed.createComponent(Host);
    await fixture.whenStable();
    // ディレクティブが付いた要素だけを集める
    const targets = fixture.debugElement.queryAll(By.directive(HighlightDirective));
    expect(targets.length).toBe(2);

    const p = targets[0].nativeElement as HTMLElement;
    p.dispatchEvent(new MouseEvent('mouseenter'));
    await fixture.whenStable();
    expect(p.style.backgroundColor).toBe('lightblue');
    expect(p.classList.contains('is-highlighted')).toBe(true);

    p.dispatchEvent(new MouseEvent('mouseleave'));
    await fixture.whenStable();
    expect(p.style.backgroundColor).toBe('');
  });
});

describe('NumericOnlyDirective', () => {
  @Component({
    imports: [NumericOnlyDirective, ReactiveFormsModule],
    template: `<input appNumericOnly [formControl]="qty" />`,
  })
  class Host {
    qty = new FormControl('', { nonNullable: true });
  }

  it('数字以外を取り除き、フォームの値もそろう', async () => {
    const fixture = TestBed.createComponent(Host);
    await fixture.whenStable();
    const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;

    input.value = '1,280円';
    input.dispatchEvent(new Event('input', { bubbles: true }));

    expect(input.value).toBe('1280');
    expect(fixture.componentInstance.qty.value).toBe('1280');
  });
});

describe('HasRoleDirective', () => {
  @Component({
    imports: [HasRoleDirective],
    template: `<button *appHasRole="'admin'; else denied">削除</button>
      <ng-template #denied><span>権限がありません</span></ng-template>`,
  })
  class Host {}

  it('権限が変わると表示が切り替わる', async () => {
    const fixture = TestBed.createComponent(Host);
    const el = fixture.nativeElement as HTMLElement;
    await fixture.whenStable();
    expect(el.querySelector('button')).toBeNull();
    expect(el.textContent).toContain('権限がありません');

    TestBed.inject(CurrentUser).roles.set(['admin']);
    await fixture.whenStable();
    expect(el.querySelector('button')?.textContent).toBe('削除');
    expect(el.textContent).not.toContain('権限がありません');
  });
});

ng test --watch=false --reporters=verbose の結果です。

 ✓ |demo| src/app/directives/article.spec.ts > HighlightDirective > マウスが乗ったら色が付き、離れたら戻る 43ms
 ✓ |demo| src/app/directives/article.spec.ts > NumericOnlyDirective > 数字以外を取り除き、フォームの値もそろう 17ms
 ✓ |demo| src/app/directives/article.spec.ts > HasRoleDirective > 権限が変わると表示が切り替わる 18ms
確かめたいこと 書き方
ディレクティブが付いた要素を集める fixture.debugElement.queryAll(By.directive(HighlightDirective))
イベントを起こす el.dispatchEvent(new MouseEvent('mouseenter'))
入力を再現する input.value = '...' のあと input.dispatchEvent(new Event('input', { bubbles: true }))
画面の更新を待つ await fixture.whenStable()
フォーカスを確かめる document.body.appendChild(fixture.nativeElement) で文書につないでから document.activeElement を見る
描画された・されていないを確かめる el.querySelector('button') が null かどうか

つまずきやすい箇所と直し方

症状・エラー 原因 直し方
NG8002: Can't bind to 'appHighlight' since it isn't a known property of 'p'. 使う側のコンポーネントの imports にディレクティブがない imports: [HighlightDirective] を追加する
TS2339: Property '...' does not exist on type '...' host の式に書いたメンバー名が、クラスのメンバーと合っていない 名前をそろえる
NG0201: No provider for TemplateRef found. 構造ディレクティブを、アスタリスクなしの属性として書いた *appXxx にするか ng-template に付ける
フォームの値が整えた値にならない 値を書き換えたあと、フォームに伝えていない input イベントを送り直す(数値のみ入力の例)

1行目のエラーは、ng build で次のように表示されました。

✘ [ERROR] NG8002: Can't bind to 'appHighlight' since it isn't a known property of 'p'. Find more at https://next.angular.dev/errors/NG8002 [plugin angular-compiler]
      7 │   template: `<p [appHighlight]="'yellow'">{{ title | truncate: 10 }...
        ╵                 ~~~~~~~~~~~~~~~~~~~~~~~~~

よくある質問

Q. ディレクティブとコンポーネントは、どう使い分けますか? A. 自分のテンプレート(見た目の構造)を持つ部品はコンポーネント、既存の要素やコンポーネントにふるまいだけを足したいときはディレクティブにします。「数値のみ入力」や「外側クリック検知」のように、いろいろな要素に同じ処理を付けたいものがディレクティブに向いています。

Q. @HostListener で書かれたコードは、書き直す必要がありますか? A. Angular 22.2.1 でも @HostBinding / @HostListener はそのまま動き、host で書いた場合と同じ結果になりました。公式の API ドキュメントは host を優先するようすすめているので、新しく書くものは host にし、既存のものは手を入れる機会に対応表で置き換えていくと負担が少なく済みます。

Q. ElementRef で要素を直接書き換えてもよいですか? A. フォーカスを当てる、contains() で内側かどうかを調べる、入力欄の値を読むといった、バインディングでは書けない操作には ElementRef を使います。クラス・スタイル・属性の切り替えは host のバインディングで書けるので、そちらを優先すると、要素を直接触るコードが減ってテストもしやすくなります。

まとめ

  • 属性ディレクティブは @Directive({ selector: '[appXxx]' }) を付けたクラス。使う側の imports に追加して、要素に属性として書く。
  • ホスト要素のクラス・スタイル・属性・イベントは host プロパティにまとめる。 @HostBinding / @HostListener と結果は同じで、公式は host を推奨している。
  • '(document:click)' や '(window:resize)' で、要素の外のイベントも受け取れる。 破棄のときの解除は Angular が行う。
  • クラスやスタイルを1つ切り替えるだけなら [class.x] / [style.x]、まとめて切り替えるなら [class] / [style] や ngClass / ngStyle。
  • 構造ディレクティブは TemplateRef と ViewContainerRef で作る。 アスタリスクは ng-template の省略記法。
  • hostDirectives で既存のディレクティブを合成できる。 入力と出力は、公開するものだけを inputs / outputs に書く。
  • テストは小さなコンポーネントに付けて、イベントを起こし、描画された HTML を確かめる。

参考・出典

  • Angular 日本語ドキュメント「属性ディレクティブ」 https://angular.jp/guide/directives/attribute-directives
  • Angular 公式ドキュメント「Structural directives」 https://angular.dev/guide/directives/structural-directives
  • Angular 公式ドキュメント「Directive composition API」 https://angular.dev/guide/directives/directive-composition-api
  • Angular 公式 API ドキュメント「HostBinding」 https://angular.dev/api/core/HostBinding

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularディレクティブの作り方|属性ディレクティブとhost」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?