8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AngularAdvent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-08

これは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-0mat-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>

基本構造の実装

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

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);
}
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を割り振り、紐づけを自動化できます。

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-labelledbyaria-controls を使って正しく紐づけられます。これにより、キーボード操作やスクリーンリーダーでの利用が大幅にスムーズになりました。

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

ScreenRecording2024-12-07at21.44.58-ezgif.com-video-to-gif-converter.gif

まとめ

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

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

明日は @sundamami さんです!

  1. わかりやすさのために大幅に簡略化しており、実際とは大きく異なります。詳しくはソースコードなどを参照してください。

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?