Angular CDKの地味だけど便利な活用方法


これは[Angular Advent Calendar 2024](https://qiita.com/advent-calendar/2024/angular)の9日目の記事です。昨日は @nontangent さんでした。

## はじめに

今年も Angular CDK の布教活動を続けるべく、ネタを探していたところ、`_IdGenerator` という新しいサービスが目に留まりました。

`@angular/cdk@19.1.0` から提供されるこのサービスは、一見地味ですが、フォームやUIコンポーネントのアクセシビリティ対応やID管理を効率化してくれます。

この記事では、Angular Material のID生成の変化と、自作コンポーネントでの`_IdGenerator`の活用方法をご紹介します！


※ 完成した自作コンポーネントはこちら

https://stackblitz.com/edit/stackblitz-starters-slzygznw?embed=1&file=src%2Fmain.ts

## ID管理が不要になるAngular Materialの仕組み

通常、フォームのラベルと入力フィールドを紐づける際には、`for` 属性と `id` 属性を手動で設定する必要があります。


```html
<div class="form-field">
  <label for="username">Username</label>
  <input id="username" type="text">
</div>
```

一方、Angular Material の `mat-form-field` を使う場合、このような設定を明示的に行う必要はありません。

```html
<mat-form-field>
  <mat-label>Username</mat-label>
  <input matInput>
</mat-form-field>
```


Angular Material では、内部的にユニークなIDを自動生成してラベル（`<mat-label>`）と入力フィールド（`<input matInput>`）を紐づけています。この仕組みにより、ID管理の手間が省けるだけでなく、設定ミスも防ぐことができます。

では、このID生成がどのように実現されているのかを見ていきましょう。

## 今までのID生成の仕組み

Angular Material では、ラベルと入力フィールドを紐づけるためにユニークなIDを自動生成する仕組みを持っています。その内部ロジックは非常にシンプルで、以下はその仕組みを簡略化した例です[^1]。

```typescript
let nextUniqueId = 0;

@Directive({ ... })
export class MatInput {
  readonly id = `mat-input-${nextUniqueId++}`;
}

@Component({
  template: `
    <label [for]="input.id">
      <ng-content select="mat-label"></ng-content>
    </label>
    <ng-content />
  `,
  ...
})
export class MatFormField {
  @ContentChild(MatInput) input: MatInput;
}
```

このコードでは、`MatInput` ディレクティブが初期化時にユニークなID（例：`mat-input-0` や `mat-input-1`）を生成し、そのIDを `MatFormField` がラベルと入力フィールドを紐づけるために使用しています。

この仕組みはシンプルで有用ですが、複数のAngularアプリケーションやバージョンが同じページで動作する場合、IDが衝突する可能性がありました。この問題を解決するため、Angularの`APP_ID`をIDに組み込む仕組みが採用され、ID生成を一元管理する`_IdGenerator` サービスが導入されました。

## 新しいID生成の仕組み

上記の課題を解決するために、Angular Material の ID生成ロジックは `_IdGenerator` を利用する形にリファクタリングされました。この変更は [angular/components#29948](https://github.com/angular/components/pull/29948) で導入されています。

```diff_typescript
+ import { _IdGenerator } from '@angular/cdk/a11y';
- let nextUniqueId = 0;

  @Directive({ ... })
  export class MatInput {
+   private _idGenerator = inject(_IdGenerator);

+   readonly id = this._idGenerator.getId('mat-input-');
-   readonly id = `mat-input-${nextUniqueId++}`;
  }
```

この変更により、ID生成が統一され、一意性が保証されただけでなく、コードの可読性や再利用性も向上しました。

## `_IdGenerator` を活用したタブ機能の実装

`_IdGenerator` は、フォームのラベルと入力フィールドを紐づけるだけでなく、ARIA属性を活用したアクセシビリティ対応にも役立ちます。ラベルとコンテンツを正確に紐づけることで、誰にとっても使いやすいUIに近づきます。

タブではラベル（ボタン）と対応するコンテンツを紐づける必要があります。これを自動で紐づけてくれるコンポーネントを作成してみましょう。タブの構造としては次のような形を目指します。

```html
<app-tab-group>
  <app-tab label="First">First Content</app-tab>
  <app-tab label="Second">Second Content</app-tab>
</app-tab-group>
```

## 基本構造の実装

タブ機能を実現するために、`Tab` と `TabGroup` の基本構造を作成します。

```typescript:tab.component.ts
import { Component, input, TemplateRef, viewChild } from '@angular/core';
import { _IdGenerator } from '@angular/cdk/a11y';

@Component({
  selector: 'app-tab',
  template: `
    <ng-template><ng-content /></ng-template>
  `,
})
export class Tab {
  readonly label = input.required<string>();

  readonly content = viewChild.required(TemplateRef);
}
```

```typescript:tab-group.component.ts
import {
  Component,
  contentChildren,
  effect,
  inject,
  signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { _IdGenerator } from '@angular/cdk/a11y';
import { Tab } from './tab.component';

@Component({
  selector: 'app-tab-group',
  imports: [NgTemplateOutlet],
  host: {
    role: 'tablist',
  }
  template: `
    @for(tab of tabs(); track $index; let i = $index) {
      <button
        role="tab"
        (click)="selected.set(i)"
      >{{ tab.label() }}</button>
    }
    @for(tab of tabs(); track $index; let i = $index) {
      <div
        role="tabpanel"
        [hidden]="!getSelected(i)"
      >
        <ng-container *ngTemplateOutlet="tab.content()"></ng-container>
      </div>
    }
  `,
})
export class TabGroup {
  readonly selected = signal(0);
  readonly tabs = contentChildren(Tab);

  getSelected(index: number) {
    return this.selected() === index;
  }
}
```

この実装により、選択されたタブに応じて対応するコンテンツを切り替える最低限の機能が実現します。

## アクセシビリティ対応のための改善

タブのラベルとコンテンツを正しく紐づけるために、ARIA属性を追加します。`_IdGenerator` を活用することで、ラベルとコンテンツに一意のIDを割り振り、紐づけを自動化できます。

```diff_typescript:tab-group.component.ts
import {
  Component,
  contentChildren,
  effect,
  inject,
  signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { _IdGenerator } from '@angular/cdk/a11y';
import { Tab } from './tab.component';

@Component({
  selector: 'app-tab-group',
  imports: [NgTemplateOutlet],
  host: {
    role: 'tablist',
  },
  template: `
    @for(tab of tabs(); track $index; let i = $index) {
      <button
        role="tab"
+       [id]="getTabId(i)"
+       [attr.aria-controls]="getTabpanelId(i)"
+       [attr.aria-selected]="getSelected(i)"
        (click)="selected.set(i)"
      >{{ tab.label() }}</button>
    }
    @for(tab of tabs(); track $index; let i = $index) {
      <div
        role="tabpanel"
+       [id]="getTabpanelId(i)"
+       [attr.aria-labelledby]="getTabId(i)"
        [hidden]="!getSelected(i)"
      >
        <ng-container *ngTemplateOutlet="tab.content()"></ng-container>
      </div>
    }
  `,
})
export class TabGroup {
+ readonly _idGenerator = inject(_IdGenerator);

+ readonly id = this._idGenerator.getId('tab-group-');
  readonly selected = signal(0);
  readonly tabs = contentChildren(Tab);

  getSelected(index: number) {
    return this.selected() === index;
  }

+ getTabId(index: number) {
+   return `${this.id}-tab-${index}`;
+ }
+
+ getTabpanelId(index: number) {
+   return `${this.id}-tabpanel-${index}`;
+ }
}
```

この改善により、タブのラベルとコンテンツが `aria-labelledby` や `aria-controls` を使って正しく紐づけられます。これにより、キーボード操作やスクリーンリーダーでの利用が大幅にスムーズになりました。

以下は、改善後のタブ機能の動作例です。

![ScreenRecording2024-12-07at21.44.58-ezgif.com-video-to-gif-converter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138755/c840109a-3d68-1bd9-6652-bc30b7f726bd.gif)

## まとめ

`_IdGenerator` は、一意のIDを簡単に生成できる便利なサービスです。その手軽さが利用を後押しし、結果的にアプリケーション全体のアクセシビリティやコードの統一感を高めてくれます。

今回の記事では、Angular Material の内部実装から、自作コンポーネントでの応用例までを紹介しました。ぜひ `_IdGenerator` を活用して、より効率的で使いやすいアプリケーションを作ってみてください！


明日は @sundamami さんです！


[^1]: わかりやすさのために大幅に簡略化しており、実際とは大きく異なります。詳しくは[ソースコード](https://github.com/angular/components/blob/0c40595296c6b8c0e83b0e23c7e6cc9605c0f7bf/src/material/form-field/form-field.ts#L186)などを参照してください。