Angular Component Dev Kit でシンプルなドロップダウンを作る

## はじめに

[Angular Material](https://material.angular.io/components/categories)にMenuとかAutocompleteとかはあるが中身をいじれるようなドロップダウンが見当たらなかったので、[Component Dev Kit(CDK)](https://material.angular.io/cdk/categories)のOverlayモジュールで中身がいじれるドロップダウンモジュールを作成します。

## 目標

完成イメージは以下の通り。
ボタンを押したらドロップダウンを表示、その外側をクリックしたら非表示になるようにします。
汎用性を上げるために、ドロップダウンの中身（some contentの部分）の実装をモジュールの外側からできるようにしておきます。
<img width="187" alt="スクリーンショット 2019-07-10 17.47.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457543/07a817da-841d-8436-f6da-c4ed78cef9e1.png">

## 準備

AngularおよびCDKをインストールします。NodeJSに関しては割愛。

```bash
$ npm install -g @angular/cli
$ ng new cdk-overlay-trial
$ cd cdk-overlay-trial/
$ npm install @angular/cdk
$ ng version

...

Angular CLI: 8.1.0
Node: 11.15.0
OS: darwin x64
Angular: 8.1.0

...
```

必要なモジュールをインポートします。

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

import { AppComponent } from './app.component';
import { OverlayModule } from '@angular/cdk/overlay'; // 追加
import { PortalModule } from '@angular/cdk/portal'; // 追加

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    OverlayModule, // 追加
    PortalModule // 追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

CSSも忘れずに。

```scss:src/styles.scss
@import "~@angular/cdk/overlay-prebuilt.css";
```

## 実装 - ガワを作る

先にガワから書いていきます。
`#dropdownContent`というテンプレート参照変数を`[appDropdownToggle]`属性に渡すようにします。`[appDropdownToggle]`というのは属性ディレクティブです。このディレクティブにドロップダウンとなるテンプレートを渡してその中でドロップダウンを表示する処理をやっていきます。
テンプレート参照変数となっている`app-dropdown-content`はコンポーネントで、Overlay表示するためにあらかじめポータル化します。

```html:src/app/app.component.html
<button [appDropdownToggle]="dropdownContent">show dropdown</button>
<app-dropdown-content #dropdownContent>
  <div>some content</div>
</app-dropdown-content>
```

前述で説明したコンポーネントとディレクティブを作成します。

- `dropdown-content` : Overlay表示となるドロップダウンの本体です。
- `dropdown-trigger` : ドロップダウンの表示/非表示を制御するディレクティブです。

```bash
$ ng generate component dropdown-content
$ ng generate directive dropdown-content/dropdown-toggle
```

作成したDropdownToggleDirectiveでテンプレート参照変数を受け取ります。

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts
import { Directive, Input } from '@angular/core';
import { DropdownContentComponent } from './dropdown-content.component';
...
export class DropdownToggleDirective {

  @Input('appDropdownToggle') dropdownContentRef: DropdownContentComponent;
...
}
```

現時点で画面はこうなっているはずです。
<img width="304" alt="スクリーンショット 2019-07-09 18.14.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457543/1ba58e0c-6443-2f68-4514-47657e11521c.png">
`dropdown-content`がそのまま表示されちゃっているのでこれをポータル化します。
そしてその子要素をドロップダウン内に表示したいので`<ng-content>`を置いときます。

```html:src/app/dropdown-content/dropdown-content.component.html
<ng-template cdk-portal>
  <div class="dropdown-wrapper">
    <ng-content></ng-content>
  </div>
</ng-template>
```

ついでにドロップダウンがそれっぽい見た目になるようにスタイルを設定しておきます。
Angular Materialが使えるなら`box-shadow`の部分は[elevation helpers](https://material.angular.io/guide/elevation)を使うといいと思います。

```scss:src/app/dropdown-content/dropdown-content.component.scss
.dropdown-wrapper {
  background-color: #FFF;
  border-radius: 4px;
  box-shadow: 0 2px 4px -1px rgba(0,0,0,.2), 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12);
}
```

## 実装 - ドロップダウンを表示する

### Overlayを表示する

DropdownToggleDirectiveにトリガーを仕掛けていきます。
`showDropdown()`の方は`@Hostlistener`デコレータを付与しておきます。ボタンをクリックしたらこのメソッドが呼び出され、ドロップダウンを表示するというわけです。
それとドロップダウンを非表示にするための`hideDropdown()`も作成しておきましょう。

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts
export class DropdownToggleDirective {
...

  @HostListener('click') showDropdown() {
  }

  hideDropdown() {
  }

}
```

今回はDropdownComponentの子要素として実装した要素をドロップダウンに表示したいので、DropdownToggleDirectiveからそいつを参照できるようにしておきます。
DropdownComponentの参照はすでに持っているので`@ViewChild`で置いておけばOKです。

```typescript:src/app/dropdown-content/dropdown-content.component.ts
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
...
export class DropdownContentComponent implements OnInit {

  @ViewChild(TemplateRef, {static: false}) templateRef: TemplateRef<any>;
...
}
```

DropdownToggleDirectiveにドロップダウンを表示する処理を追加していきます。
Overlayのコンテナを作って、ポータル化したテンプレートのインスタンスを作って、コンテナにインスタンスをアタッチします。（この認識というか表現であってるか自信ない）

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts
import { Directive, Input, HostListener, ViewContainerRef } from '@angular/core';
import { DropdownContentComponent } from './dropdown-content.component';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
...
export class DropdownToggleDirective {
...
  overlayRef: OverlayRef;

  constructor(
    private overlay: Overlay,
    private viewContainerRef: ViewContainerRef
  ) { }

  @HostListener('click') showDropdown() {

    this.overlayRef = this.overlay.create();

    const templatePortal = new TemplatePortal(this.dropdownContentRef.templateRef, this.viewContainerRef);

    this.overlayRef.attach(templatePortal);

  }
...
}
```

これで一応、ボタンをクリックしたらドロップダウン（っぽいもの）が出てくると思います。

### Overlayの位置を調整する

位置を調整するための設定を追加します。あとで、位置調整以外の設定も追加します。
`OverlayConfig`を作成して`this.overlay.create();`の時に渡して適用してやります。

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts
import { Directive, Input, HostListener, ViewContainerRef, ElementRef } from '@angular/core';
import { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay';
...
  constructor(
    private overlay: Overlay,
    private viewContainerRef: ViewContainerRef,
    private viewElement: ElementRef
  ) { }

  @HostListener('click') showDropdown() {

    const config = this._generateOverlayConfig();

    this.overlayRef = this.overlay.create(config);
...
  }

  private _generateOverlayConfig(): OverlayConfig {

    const overlayPosition = this.overlay.position()
      .flexibleConnectedTo(this.viewElement)
      .withPositions([{
        originX: 'start',
        originY: 'bottom',
        overlayX: 'start',
        overlayY: 'top'
      }]);

    return {
      positionStrategy: overlayPosition
    };
  }
...
```

位置調整について少し細かく見ていきましょう。

`.flexibleConnectedTo(this.viewElement)`でOverlayの位置の基準を設定しています。`this.viewElement`はこのディレクティブが設定されているDOMの参照になります。つまり、今回の場合は`button`要素を基準としてOverlayの位置を設定していくことになります。
`.flexibleConnectedTo()`の他に`.global()`があります。その名の通り位置の基準をグローバル、つまりウィンドウ全体を基準にして位置を決めていくことになります。画面全体に被さるようなモーダルを作成するときなんかはこちらを使うといいですね。

`.withPositions()`で具体的な位置を設定します。指定できる値は以下の通りです。

- originX, overlayX: 'start' | 'end' | 'center'
- originY, overlayY: 'top' | 'bottom' | 'center'

上記以外にoffsetX, offsetYがあり、微調整も可能なようです。
（すみませんが、どの値を設定したらどうなるかをちゃんと把握してません...あとで検証します。一応[ドキュメント](https://material.angular.io/cdk/overlay/api#ConnectionPositionPair)には記載ありますが私の頭が足りないようです...）

### Overlayを非表示にする

このままだと表示したドロップダウンが何をしても非表示にならないので、ドロップダウン以外の要素をクリックした時に非表示になるようにします。

`hasBackdrop`を`true`にしてOverlayより背面側の要素のクリックなどをブロックします。
そして`backdrop`のデフォルトのCSSが当たらないように`backdropClass`を別に設定してあげます。これを設定しないと`backdrop`がグレーになります。

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts
  private _generateOverlayConfig(): OverlayConfig {
...
    return {
      positionStrategy: overlayPosition,
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop'
    };
  }
```

`hideDropdown()`にOverlayを非表示にするための処理を追加します。そして、ポータルをアタッチした段階で`backdrop`をクリックした時に非表示になるようにセットしておきます。

```typescript:src/app/dropdown-content/dropdown-toggle.directive.ts

  @HostListener('click') showDropdown() {
...
    this.overlayRef.attach(templatePortal);

    this.overlayRef.backdropClick().subscribe(() => this.hideDropdown());

  }
...
  hideDropdown() {
    this.overlayRef.dispose();
  }

}

```

あとはドロップダウン内の要素のスタイルを適当に設定してあげればそれっぽくなります。

```html:src/app/app.component.html
<button [appDropdownToggle]="dropdownContent">show dropdown</button>
<app-dropdown-content #dropdownContent>
  <div style="padding: 8px">some content</div>
</app-dropdown-content>
```

## おわり

どうにか色々使いまわせるドロップダウンが作れないかとこねくり回した結果こうなりました。
@Quramy さんの[記事](https://qiita.com/Quramy/items/caf015e56d536411d4ba)でもおっしゃられていますがサンプルが本当に少ないです。私の場合はAngular Materialのテストコードをみて使い方を確認していました。

ただ、必須と言っていいくらいの機能ばかりなのでもっと広まってくれると嬉しいです。
（AngularJSを触っていた頃は後ろに隠れるドロップダウンに悩まされていました...）

## 参考

- Angular Component Dev Kit 入門 - https://qiita.com/Quramy/items/caf015e56d536411d4ba
- API reference for Angular CDK overlay - https://material.angular.io/cdk/overlay/api
- How to create a custom dropdown using Angular CDK - http://prideparrot.com/blog/archive/2019/3/how_to_create_custom_dropdown_cdk