これはAngular Advent Calendar 2024の9日目の記事です。昨日は @nontangent さんでした。
はじめに
今年も Angular CDK の布教活動を続けるべく、ネタを探していたところ、_IdGenerator
という新しいサービスが目に留まりました。
@angular/cdk@19.1.0
から提供されるこのサービスは、一見地味ですが、フォームやUIコンポーネントのアクセシビリティ対応やID管理を効率化してくれます。
この記事では、Angular Material のID生成の変化と、自作コンポーネントでの_IdGenerator
の活用方法をご紹介します!
※ 完成した自作コンポーネントはこちら
ID管理が不要になるAngular Materialの仕組み
通常、フォームのラベルと入力フィールドを紐づける際には、for
属性と id
属性を手動で設定する必要があります。
<div class="form-field">
<label for="username">Username</label>
<input id="username" type="text">
</div>
一方、Angular Material の mat-form-field
を使う場合、このような設定を明示的に行う必要はありません。
<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。
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 で導入されています。
+ 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に近づきます。
タブではラベル(ボタン)と対応するコンテンツを紐づける必要があります。これを自動で紐づけてくれるコンポーネントを作成してみましょう。タブの構造としては次のような形を目指します。
<app-tab-group>
<app-tab label="First">First Content</app-tab>
<app-tab label="Second">Second Content</app-tab>
</app-tab-group>
基本構造の実装
タブ機能を実現するために、Tab
と TabGroup
の基本構造を作成します。
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);
}
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を割り振り、紐づけを自動化できます。
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
を使って正しく紐づけられます。これにより、キーボード操作やスクリーンリーダーでの利用が大幅にスムーズになりました。
以下は、改善後のタブ機能の動作例です。
まとめ
_IdGenerator
は、一意のIDを簡単に生成できる便利なサービスです。その手軽さが利用を後押しし、結果的にアプリケーション全体のアクセシビリティやコードの統一感を高めてくれます。
今回の記事では、Angular Material の内部実装から、自作コンポーネントでの応用例までを紹介しました。ぜひ _IdGenerator
を活用して、より効率的で使いやすいアプリケーションを作ってみてください!
明日は @sundamami さんです!