React + TypeScript でポップアップの便利クラスを作った話

最近 React + TypeScript でポップアップの便利クラスを作ったので、主に設計面中心で解説してみます。

## その前に

記事を書いていて React Popup で検索したら、モーダル系の物ばかり出てきたので、世の中的にはそっちを指すようですね。
PopupMenu で検索するとモーダルではないコンテキストメニューっぽいものが出てきます。
Popup に Menu を足したらモーダルが失われるというのは不思議です。

Popup はただ飛び出す意味しか持っておらず、何をの部分は PopupXxx に入るので、
Popup でモーダルウインドウを指している歪が不思議な現象を引き起こしているように思いました。

今回対象にしているのは、純粋な Popup なので、何かを飛び出させるものと捉えてください。
ポップアップ対象を何とするかは自由です。
オーバーレイと組み合わせればモーダルにもできる、といった感じの素のPopup機能のお話です。

## 前提

- 目的
  - HTMLとして必要な属性を適切に付けること
  - イベントのハンドリングを適切に行うこと
  - 使い方がわかりやすいこと
  - 既存のコンポーネント構造の流れに後から追加できること
  - 連携したときに新たな制約を作らないこと
- スタイルに関して
  - 今回の内容にスタイルは含みません
  - スタイルは className を当てて別途 scss で書く想定です

## ポップアップあるある

ポップアップを作っているとよく遭遇する問題。。。

- ポップアップの状態管理のためにコンテナコンポーネントを用意するのは面倒
- ビューに状態を持ったら持ったでコンテナから参照したくなったときに面倒
- 簡易的に作ったはいいものの、全然違うところの処理からポップアップを閉じたいと言われて超困る
- トグルボタンとパネルの場所が離れていると処理が散らばって大変
- パネルの外部クリックの対象にトグルボタンも含まれるので、トグルボタンで閉じようとしたときに誤動作しがち
- トグルボタンにonClickを渡したらポップアップ処理で使用するハンドラと競合して動かない
- なんだかんだで同じような処理を毎回書くハメになる
- ちゃんとやろうとすると各種Propsの管理が煩わしい
  - ポップアップ対象の `id` `aria-hidden` を適切に割り当てないといけない
  - トグルボタンの `aria-haspopup` `aria-controls` `aria-expanded` `aria-pressed` を適切に割り当てないといけない

何度か便利クラスを作ったものの、それなりに課題が解決できていなかったので、ちょっと気合いを入れて作り込んでみることにしました。
作り込むと言っても、でっかく作るのではなく、小回りが効く使い勝手の良いインタフェースを目指すという意味です。
無駄なく最大の効率を目指して。

## ポップアップ対応前のコンポーネント

トグルボタンとパネルがあるとします。

```Foo.tsx
import { OutsideClick } from "./OutsideClick";

// HOCにより `<Panel onOutsideClick={() => { /* パネルの外クリックのイベントハンドラ */ }} />` に対応
const Panel = OutsideClick.attach("div");

export interface Props {
  toggleButton: React.ButtonHTMLAttributes<HTMLButtonElement>;
  // `OutsideClick.Props` は実質 `{ onOutsideClick: (event) => void }` です
  panel: React.HTMLAttributes<HTMLElement> & OutsideClick.Props;
}

export class Component extends React.Component<Props> {
  render() {
    return (
      <div>
        <button
          {...this.props.toggleButton}
        >パネルの開閉</button>
        <div
          {...this.props.panel}
        >パネル</div>
      </div>
    );
  }
}
```

## ポップアップ対応後のコンポーネント

必要最小限の5箇所に簡単な更新を加えただけです。

```Foo.tsx
import { PopupController } from "./PopupController";
import { OutsideClick } from "./OutsideClick";

const Panel = OutsideClick.attach("div");

export interface Props {
  toggleButton: React.ButtonHTMLAttributes<HTMLButtonElement>;
  // 1. Propsを足した
  panel: React.HTMLAttributes<HTMLElement> & OutsideClick.Props & PopupController.Props;
}

export class Component extends React.Component<Props> {
  // 2. PopupController のインスタンスを作成する
  // 第一引数は panel の props を必要なタイミングで PopupController が扱うための関数
  // 第二引数は可視状態が変更されたときに呼び出す再描画用関数を渡しています
  private popupController = new PopupController(() => this.props.panel, this.forceUpdate.bind(this));

  componentDidMount() {
    // 3. マウント時に onMount を呼ぶ(onMount使わないなら省略可)
    this.popupController.emitOnMount();
  }

  render() {
    return (
      <div>
        <button
          {...this.props.toggleButton}
          // 4. コントローラ用の props を追加で渡す
          // 引数にトグルボタンの Props を渡すことで、 PopupController がハンドリングする onClick の前に、 toggleButton の onClick を呼んでくれる
          {...this.popupController.generateControllerProps(this.props.toggleButton)}
        >パネルの開閉</button>
        <Panel
          {...this.props.panel}
          // 5. ポップアップ対象用の props を追加で渡す
          {...this.popupController.generateTargetProps()}
        >パネル</Panel>
      </div>
    );
  }
}
```

`generateControllerProps` と `generateTargetProps` が独立しているので、構造に依存せず好きな要素に機能を提供することができます。

## 出力イメージ

ざっとこんな感じになります。
パネルの開閉状態に応じて `aria-hidden` `aria-expanded` `aria-pressed` が変化します。

```出力.html
<div>
  <button aria-haspopup="true" aria-controls="id-1887696505" aria-expanded="false" aria-pressed="false">パネルの開閉</button>
  <div id="id-1887696505" aria-hidden="true">パネル</div>
</div>
```

`id` は指定が無ければランダムなものを自動的に割り当てるので、特にこだわりがなければ何もせずとも解決され、 `aria-controls` と連動します。
この `id` の結びつきにより、トグルボタンをクリックしてもパネルの外部クリックは反応せず、パネルを閉じた瞬間にトグルクリックでパネルが開くといった二重の動作が起こらないようになっています。

## PopupController のインタフェース

実装は書きませんが、インタフェースだけ書いておきます。
名前が競合せず、冗長にもならず、型がわかりやすく、意味や役割がはっきりするように設計しています。

```PopupController.tsx
export namespace PopupController {
  /**
   * 可視状態変更イベントのオブジェクト.
   */
  export type VisibilityChangeEvent = {
    visibility: boolean;
    preventDefault: () => void;
  };

  /**
   * 可視状態更新イベントのハンドラ.
   */
  export type VisibilityChangeHandler = (event: VisibilityChangeEvent) => void;

  /**
   * ポップアップを操作するためのインタフェース.
   */
  export interface Driver {
    /**
     * 使用されているID.
     */
    readonly id: string;

    /**
     * 現在の表示状態.
     */
    readonly visibility: boolean;

    /**
     * 表示状態を切り替える.
     */
    toggleVisibility();

    /**
     * 表示状態を更新する.
     */
    updateVisibility(visibility: boolean);
  }

  /**
   * ポップアップ対象が実装するインタフェース.
   * `popupController` は中身の同じプロパティ名が衝突する可能性を避けるための名前空間.
   * `onMount` などはフラットな構造に置くとすぐ競合してしまうので、名前空間が重要になってくる.
   */
  export interface Props {
    popupController?: {
      onVisibilityChange?: VisibilityChangeHandler;
      onMount?: (driver: Driver) => void;
    };
  }
}

export class PopupController {
  // クラスの実装は省略
}
```

## 実際に使用する場合のイメージ

あくまで簡易的に書いているので細かい所のツッコミはご容赦頂くとして、実際の使用感としては以下のようなイメージになります。
パネルに `popupController` が生えたくらいで、後は至って普通のJSXです。

`PopupController` で使用しているイベントハンドラは競合を回避する仕組みが入っているので、
使う側はどのイベントハンドラを渡しても意図通りに反応します。

機能を強化するときに何かを失わないか慎重に考えながら作ることはなかなか難しいですが、実現できると非常に強力です。

```Example.tsx
import * as Foo from "./Foo";

let state: { driver?: PopupController.Driver } = {};

const element = (
  <Foo.Component
    toggleButton={{
      // トグルボタンクリック時、ここが実行された後にパネルの可視状態が変化する(イベントハンドラが競合しないので全イベントを無意識に使える)
      onClick: () => {
        console.log("トグルボタンをクリック");
      },
    }}
    panel={{
      popupController: {
        // マウント時に呼ばれる
        onMount: (driver: PopupController.Driver) => {
          // ドライバを保持する
          state.driver = driver;
        },
        // 可視状態更新時に呼ばれる
        onVisibilityChange: (event: PopupController.Event) => {
          console.log(event.visibility);
        },
      },
      // パネルがダブルクリックされたら不可視化する
      onDoubleClick: () => {
        if (state.driver) {
          state.driver.updateVisibility(false);
        }
      },
      // panelは PopupController 内で自動的にイベントハンドラの競合回避処理を行っているので、
      // ここで渡したハンドラもちゃんと呼ばれます.
      onOutsideClick: () => {
         console.log("パネル外クリック");
      }
    }}
  />
);
```

## まとめ

- ポップアップの状態管理のためにコンテナコンポーネントを用意しなくてよい
- ビューに状態を持っていてもコンテナから `Driver` 経由で操作が可能
- 全然違うところの処理からポップアップを閉じたいと言われても `Driver` を呼び出す処理を繋げるだけで解決できる
- トグルボタンとパネルの場所が離れていても処理が散らばらない(処理は PopupController に閉じている)
- トグルボタンとパネルの関係性が解決されているので、外部クリックの誤動作が起きる心配がない
- ハンドラの競合解決は `PopupController` が全部やってくれる
- 共通処理は `PopupController` が全部やってくれる
- 各種Propsの管理は `PopupController` が全部やってくれる

必要な `props` は全て `popupController.generateControllerProps()` や `popupController.generateTargetProps()` で供給されるので、使う側はいつも同じように書けばよく、特に何も考える必要がありません。

要素同士が離れた場所にあったとしても、共通の親コンポーネントに `PopupController` のインスタンスを作れば良いだけです。

今回は純粋なポップアップ機能について、設計面を中心にして紹介しました。
汎用的な機能を実現するため、新たな制約を生み出さず、必要最小限の接点で利用可能にしたところがポイントです。

責務の境界に対する意識や、命名のパターンなどにも注目してみると、洗練された設計やインタフェースの参考になるかと思います。

## おまけ

`PopupContrller` はもう少し工夫すれば、もっと自由にトリガーとなるイベントを組み合わせられるようにできそうです。
例えば、ハンドリングするイベントのマップ定義をコンテキストとして注入するなど。

また、現時点でも型推論をもう少し頑張れば以下の段階までは便利にできそうだったりします。
`this.forceUpdate.bind(this)` と `this.forceUpdate` では結果が違ってしまうので、間違える可能性を減らせる点で価値があります。

```tsx
// このインタフェースは
private popupController = new PopupController(() => this.props.panel, this.forceUpdate.bind(this));
// ここまでは改善できそう
private popupController = new PopupController(this, ({ panel }) => panel);
// this である ReactElement は props と forceUpdate を持っている.
// PopupController が ReactElement を標準的に扱うのであれば(Reactを使っているので必然的にそうなる)、
// this から ReactElement<P> の推論が効くので、引数で渡した this の props に panel が存在することが保証できる.
// これにより第二引数の関数の引数で panel を分割代入で抽出することが可能になる.
// 第二引数の関数の戻り値の PopupController.Props を実装している型という制約を満たしている.
```

ただ、これ以上進むと問題がでてきます。

```tsx
// 型的にはここまで通せそうだが、これは改悪.
private popupController = new PopupController(this, "panel");
// this.props 直下の PopupController.Props しか扱えない制約を作ってしまうため.
// もっと深い階層に PopupController.Props を実装したポップアップ対象がいる場合に扱えなくなってしまう.
```

どこまでが改善で、どこからが改悪なのか、踏み込んではいけない境界もあったりはしますし、そこを見極めるときの視点や考え方もあったりします。
何も失わずに機能を強化することがとても重要ですね。
このあたりを書き始めると細かくなるので今回は終わります。