LoginSignup
2
2

More than 1 year has passed since last update.

Angularで汎用的なツールチップComponentとDirectiveを作る

Last updated at Posted at 2023-03-22

普段Angular MaterialのMatTooltipModuleを使ってますが、あまり柔軟にできないので自作しました。

実現したいこと

・ツールチップに画像挿入したい
・任意の場所で改行できるようにしたい
・ツールチップのサイズを制御したい

こういうやつ

「tomoyanp」の部分をマウスホバーしたら、画像付きツールチップが表示される。
image.png

実装上実現したいこと

・MatTooltipModuleと同じ使い心地としたく、下記のようにDirective的に呼び出したい

こんなイメージ

<div
  appCustomTooltip
  message="ツールチップのメッセージです。"
  [width]="200"
  [path]="assets/img/sample.png">
  ここをホバーするとツールチップが表示されるよ
</div>

どう作る?

〇Directive
 ・マウスイベントを捕捉
  → hoverされたこととhoverが外れたことの捕捉
 ・tooltip用コンポーネントをoverlayで表示・非表示
 ・呼び出し元から@Inputを受け取り、tooltip表示用Componentに渡す
 ・hoverが発生した際のマウスのX座標、Y座標をtooltip表示用Componentに渡す

〇Component
 ・DirectiveからInjector経由で引数を受け取る
 ・引数に応じてツールチップを表示する

前提

angular cdkを利用してoverlayを表示するため、最低限angular/cdkだけインストールしてください

npm install @angular/cdk

実装

Directive

custom-tooltip.directive.ts
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
  Directive,
  HostListener,
  InjectionToken,
  Injector,
  Input,
} from '@angular/core';
import {
  CustomTooltipArgs,
  CustomTooltipComponent,
  CustomTooltipPosition,
} from './custom-tooltip.component';

// Injector経由で参照する際のトークン
export const TOOLTIP_OVERLAY_DATA = new InjectionToken<any>(
  'TOOLTIP_OVERLAY_DATA'
);

// 画像挿入したい場合のパス。assets配下に限定するために型定義しておく
type AssetsPath = `assets/${string}`;

@Directive({
  selector: '[appCustomTooltip]',
})
export class CustomTooltipDirective {
  // ツールチップで表示するメッセージ
  // 改行を入れたい場合、string配列を渡すと要素ごとに改行されるような仕様にする
  @Input() message?: string | string[];
  // 画像を挿入したい場合、assetsからのパスを指定する
  @Input() path?: AssetsPath;
  // ツールチップ自体の横幅を制御したい場合は指定させる
  @Input() width?: number;
  // ツールチップをホバーした場所から見てどこに配置するか(上下)
  @Input() position?: CustomTooltipPosition;

  private overlayRef?: OverlayRef;

  // injectorとoverlayはDI
  constructor(private injector: Injector, private overlay: Overlay) {}

  // hoverしたイベントを捕捉
  @HostListener('mouseenter', ['$event'])
  onMouseenter($event: MouseEvent) {
    this.createTooltip($event);
  }

  // hoverが外れたイベントを捕捉
  @HostListener('mouseleave', ['$event'])
  onMouseleave() {
    if (this.overlayRef == null) {
      return;
    }

    // ツールチップ消す
    this.overlayRef.detach();
  }

  private createTooltip($event: MouseEvent) {
    this.overlayRef = this.overlay.create();

    // 表示用Componentに渡すデータの作成
    const args: CustomTooltipArgs = {
      message: this.message,
      path: this.path,
      position: this.position,
      width: this.width,
      x: $event.clientX, // マウスのX座標も渡す
      y: $event.clientY, // マウスのY座標も渡す
    };

    // Injectorを作成して渡すデータをuseValueで設定する
    const injector = Injector.create({
      parent: this.injector,
      providers: [
        {
          provide: TOOLTIP_OVERLAY_DATA,
          useValue: args,
        },
      ],
    });

    // 作成したInjectorを渡す
    // CustomTooltipComponentが、これ以降で作成するツールチップ表示用Componentです
    const componentPortal: ComponentPortal<CustomTooltipComponent> =
      new ComponentPortal(CustomTooltipComponent, null, injector);

    // overlayの表示
    this.overlayRef.attach<CustomTooltipComponent>(componentPortal);
  }
}

ポイント
・Injector経由でComponentにデータを渡す
・mouseenterイベントが発生した時点でのマウスのX座標、Y座標をComponentに渡す
 ※X座標、Y座標はtooltipをposition: absoluteで表示する際の位置決めに使います

Component

custom-tooltip.component.ts

import { Component, Inject } from '@angular/core';
import { TOOLTIP_OVERLAY_DATA } from './custom-tooltip.directive';

// ツールチップの配置位置はとりあえず上下どちらかにしとく(左右とか入れてもよいけどね)
export type CustomTooltipPosition = 'above' | 'below';
export type CustomTooltipArgs = {
  message?: string | string[];
  path?: string;
  position?: CustomTooltipPosition;
  width?: number;
  x: number;
  y: number;
};

@Component({
  selector: 'app-custom-tooltip',
  templateUrl: './custom-tooltip.component.html',
  styleUrls: ['./custom-tooltip.component.scss'],
})
export class CustomTooltipComponent {
  messages?: string[];
  private offset: number;

  // Injector参照用のトークンを指定することで、InjectorのuseValueを取ることができる
  constructor(@Inject(TOOLTIP_OVERLAY_DATA) public data: CustomTooltipArgs) {
    // positionに応じて、どの程度ズラすか
    const offsetBase = 20;
    if (data.position == null) {
      // positionが指定されてない場合、下に表示されるようにする
      this.offset = offsetBase;
    } else {
      // aboveが指定されてる場合、マイナスオフセットを渡して相対的に上に表示されるようにする
      this.offset = data.position === 'below' ? offsetBase : -offsetBase;
    }

    // messageは文字列か文字列配列のどちらかの可能性があるため、文字列配列で統一させる
    if (data.message != null) {
      // 文字列の場合文字列配列に直して返却
      this.messages =
        typeof data.message === 'string' ? [data.message] : data.message;
    }
  }

  createStyle() {
    // 渡された引数に応じてstyleを作成する
    // position: absoluteを指定し、leftにX座標、topにY座標 + constructorで計算したoffsetとすることで表示位置を調整
    return `
      position: absolute;
      z-index: 1;
      left: ${this.data.x}px;
      top: ${this.data.y + this.offset}px;
      width: ${this.data.width == null ? '200' : this.data.width}px;
    `;
  }
}
custom-tooltip.component.html
<div [style]="createStyle()">
  <div class="container">
    <ng-container *ngIf="messages">
      <div *ngFor="let message of messages">
        {{ message }}
      </div>
    </ng-container>
    <div class="image" *ngIf="data.path">
      <img [src]="data.path" />
    </div>
  </div>
</div>
custom-tooltip.component.scss
// まあこのへんは、、、好きにやってください!
.container {
  padding: 4px 8px;
  background-color: #616161;
  font-size: 12px;
  color: white;
  border-radius: 4px;
  width: fit-content; // 文字数に合わせてツールチップをフィットさせたい
  max-width: 100%;
  overflow-wrap: break-word; // はみ出したら折り返し
  .image {
    img {
      width: 100%; // tooltipの横幅いっぱいに画像表示する
    }
  }
}

実際に使ってみるとこんな感じ

app.component.html
<div class="container">
  <div
    appCustomTooltip
    [width]="200"
    [message]="[
      'しがないアラサーエンジニア',
      '最近自身のキャリアについて悩んでいるらしい'
    ]"
    [path]="'assets/img/tomoyanp.png'"
    [position]="'below'"
  >
    tomoyanp(ここをhoverするとツールチップが表示されるよ)
  </div>
</div>

非hover時

image.png

hover時

image.png

以上です。
ほぼ、そのままコピペで動くと思います。(動かなければご連絡ください)
そこそこ良いのが出来た気がするんですが、改善点あれば教えてくださいませ。

最後に

いらないと思うけど一応app.module.ts載せときます

app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CustomTooltipComponent } from './custom-tooltip/custom-tooltip.component';
import { CustomTooltipDirective } from './custom-tooltip/custom-tooltip.directive';

import { OverlayModule } from '@angular/cdk/overlay';

@NgModule({
  declarations: [AppComponent, CustomTooltipComponent, CustomTooltipDirective],
  imports: [BrowserModule, AppRoutingModule, OverlayModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
2
2
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
2
2