Angular CDK の AriaDescriber を使ったアクセシビリティ対応 Tooltip の作り方


これは[Angular Advent Calendar 2024](https://qiita.com/advent-calendar/2024/angular)の2日目の記事を変わりに投稿しています。昨日は @kasaharu さんでした。

## はじめに

9日目の記事でも Angular CDK について書いていますが、今回も懲りずに Angular CDK の布教をしていきます。今回は、CDK の @angular/cdk/a11y に含まれる AriaDescriber を使い、アクセシブルな Tooltip を作成する方法を紹介します。

Tooltip（ツールチップ）は、UIで頻繁に使われるコンポーネントの一つですが、アクセシビリティを考慮しないと、スクリーンリーダー利用者にとっては適切に機能しません。Angular CDK の AriaDescriber を使うことで、こうした課題を簡単に解決できます。


※ 完成したものはこちら

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

## Tooltip の実装

まずは、シンプルな Tooltip を実装します。今回の実装は最低限の動きに焦点を当てており、実用的なすべての要素を網羅しているわけではありません。Angular CDK の Overlay を使って表示位置を制御し、Portal を活用して Tooltip を描画します。

以下が Tooltip を構成するディレクティブのコードです。

```typescript:tooltip.directive.ts
import { Directive, effect, ElementRef, inject, input } from '@angular/core';
import {
  Overlay,
  OverlayRef,
  STANDARD_DROPDOWN_BELOW_POSITIONS,
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { TooltipComponent } from './tooltip.component';

@Directive({
  selector: '[appTooltip]',
  host: {
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()',
  },
})
export class TooltipDirective {
  private readonly elementRef = inject(ElementRef);
  private readonly overlay = inject(Overlay);

  private overlayRef?: OverlayRef;

  readonly appTooltip = input.required<string>();

  show() {
    if (this.overlayRef?.hasAttached) {
      this.overlayRef?.detach();
    }

    const positionStrategy = this.overlay
      .position()
      .flexibleConnectedTo(this.elementRef)
      .withPositions(STANDARD_DROPDOWN_BELOW_POSITIONS);

    this.overlayRef = this.overlay.create({
      positionStrategy,
    });

    const portal = new ComponentPortal(TooltipComponent);
    const tooltip = this.overlayRef.attach(portal).instance;
    tooltip.message.set(this.appTooltip());
    tooltip.markForCheck();
  }

  hide() {
    if (this.overlayRef?.hasAttached) {
      this.overlayRef.detach();
    }
  }
}
```

Tooltip の表示内容を担うコンポーネントも用意します。このコンポーネントは、Tooltip のメッセージを受け取り表示するシンプルなものです。

```typescript:tooltip.component.ts
import { ChangeDetectorRef, Component, inject, signal } from '@angular/core';

@Component({
  selector: 'app-tooltip',
  template: '{{ message() }}',
})
export class TooltipComponent {
  private readonly cdr = inject(ChangeDetectorRef);

  readonly message = signal('');

  markForCheck() {
    this.cdr.markForCheck();
  }
}
```

これで基本的な Tooltip が完成しました。

## AriaDescriber を使ったアクセシビリティの向上

次に、Angular CDK の AriaDescriber を活用して Tooltip のアクセシビリティを向上させます。

AriaDescriber は、要素に説明テキストを付加し、スクリーンリーダーに正しく認識させるための Angular CDK のユーティリティです。

以下のコードを TooltipDirective に追加することで、Tooltip の説明が適切にスクリーンリーダーで読み上げられるようになります。

```diff_typescript:tooltip.directive.ts
export class TooltipDirective {
  private readonly elementRef = inject(ElementRef);
  private readonly overlay = inject(Overlay);
+ private readonly ariaDescriber = inject(AriaDescriber);

  private overlayRef?: OverlayRef;

  readonly appTooltip = input.required<string>();

+ constructor() {
+   // 本来は変更のタイミングでも AriaDescriber.removeDescription を利用して、既存の説明を適切に削除する必要があります
+   effect(() => {
+     const message = this.appTooltip();
+
+     this.ariaDescriber.describe(
+       this.elementRef.nativeElement,
+       message,
+       'tooltip'
+     );
+   });
+ }
+
+ ngOnDestroy() {
+   this.ariaDescriber.removeDescription(
+     this.elementRef.nativeElement,
+     this.appTooltip()
+   );
+ }
+
  show() {
    if (this.overlayRef?.hasAttached) {
      this.overlayRef?.detach();
    }

    const positionStrategy = this.overlay
      .position()
      .flexibleConnectedTo(this.elementRef)
      .withPositions(STANDARD_DROPDOWN_BELOW_POSITIONS);

    this.overlayRef = this.overlay.create({
      positionStrategy,
    });

    const portal = new ComponentPortal(TooltipComponent);
    const tooltip = this.overlayRef.attach(portal).instance;
    tooltip.message.set(this.appTooltip());
    tooltip.markForCheck();
  }

  hide() {
    if (this.overlayRef?.hasAttached) {
      this.overlayRef.detach();
    }
  }
}
```

これで、Tooltip の内容がスクリーンリーダーで認識されるようになります。

## 実際の動作例

この Tooltip を使うと、次のように動作します。ボタンにホバーすると Tooltip が表示され、同時にスクリーンリーダーが「Search」という情報を正確に読み上げます。

AriaDescriber を追加する前は、Tooltip は画面上には表示されても、スクリーンリーダーには情報が伝わりません。しかし、AriaDescriber を使用することで、Tooltip の説明が aria-describedby 属性として要素に付加され、スクリーンリーダーで「Search」と読み上げられるようになります。

以下は、AriaDescriber を追加する前後の HTML 構造の違いを示したものです[^1]。

![Tooltip + AriaDescriber - Before.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138755/8bb7477b-c113-d5f4-01e5-b5d773866d00.jpeg)

![Tooltip + AriaDescriber - After.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138755/c21d17f4-2eaf-2ed2-32f2-dbd266f6bdca.jpeg)

以下のようなコードで、この動作を実現できます。

```html
<button appTooltip="Search">
  <span aria-hidden="true" class="material-symbols-outlined">search</span>
</button>
```

## まとめ

Angular CDK の AriaDescriber を利用することで、Tooltip をスクリーンリーダー対応させるための説明テキストを簡単に管理できるようになりました。ARIA 属性を手動で設定する手間が省けるだけでなく、複数要素間で説明を効率的に再利用できる点も大きなメリットです。

ただし、実際の運用では、Tooltip の表示タイミングや非表示の際の挙動、他のアクセシビリティ要件との統合など、さらに考慮すべき点がたくさんあります。この記事で紹介した内容はその一部ですが、こうした方法を一つの参考として活用いただければと思います。

この記事を参考に、ぜひ AriaDescriber をプロジェクトに取り入れて、アクセシビリティ対応の向上に役立ててください！

明日は @lacolaco さんです！

[^1]: ARIAの検証は [WAVE Evaluation Tool](https://chromewebstore.google.com/detail/wave-evaluation-tool/jbbplnpkjmmeebjpijfedlgcdilocofh?hl=en)というChrome拡張機能を利用しています。