Reactで入力候補機能を実装

#概要
Reactの勉強としてwebアプリを作る中で、以下のようなテキスト入力中のサジェスト機能をカスタムフック「useSuggestion」として実装したので実装方法を記載します。
(追記)サンプルのコードをgithubに公開しました。
https://github.com/thashimoto123/useSuggestionSample

![GIF 2020-08-06 9-53-38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453689/7e66c375-b53a-3bb3-178e-d04391ed976c.gif)


#開発環境
```
"react": "^16.13.1"
"typescript": "^3.9.7"
```

#使い方


###引数
1. 文字列
2. 単語リスト
3. 入力候補を選択した時に実行されるコールバック関数
4. 単語検索用のフィルター関数（省略可能）

引数に文字列、単語リスト、入力候補を選択した時に実行されるコールバック関数を渡します。
デフォルトでは単語リストとして文字列が入った配列を渡す必要がありますが、自作したフィルター関数を第4引数に渡すことでオブジェクトが入った配列を扱うことも出来ます。

###返り値
* suggestions: 入力候補のリスト
* activeIndex: 現在選択中の入力候補のインデックス（初期値は-1）

返り値の入力候補リストをulタグなどで表示すればOKです。
矢印キーやEnterキーを押したときの処理はuseSuggestion内に含まれています。

```tsx:使用例SuggestionList

interface SuggestionListProps {
  text: string;
  words: string[];
  callbackSelectSuggestion: any;
}

const SuggestionList: React.FC<SuggestionListProps> = ({
  text,
  words,
  callbackSelectSuggestion
}) => {
  const { suggestions, activeIndex } = useSuggestion(text, words, callbackSelectSuggestion);

  return (
    <>
    { 
      (suggestions.length > 0) && 
      <ul className="suggestion-list">
      {
        suggestions.map((suggestion, i) => {
          const isActive = i === activeIndex ? 'is-active' : '';
          return  (
            <li 
              key={suggestion} 
              className={isActive} 
              onClick={()=>{ callbackSelectSuggestion(suggestion) }}
            >
              {suggestion}
            </li>
          )
        })
      }
      </ul>
    }
    </>
  )
}

export default SuggestionList;
```

#実装内容

##useSuggestion
useSuggestionのコードは以下のようになります。
主に入力候補の取得とKeydownイベントの処理の登録を行っています。
デフォルトのフィルター関数には後述する[getWordsContainingString](#getWordsContainingString)を使用しています。その他、keydownイベント用関数を生成するヘルパー関数として[createKeydownHandler](#createKeydownHandler)を読み込んでいます。

```typescript:useSuggestion.ts
import { useMemo, useState, useCallback, useEffect } from 'react';
import getWordsContainingString from './getWordsContainingString';
import createKeydownHandler from './createKeydownHandler';

interface UseSuggestion {
  (
    str: string,
    list: any[],
    callbackSelectSuggestion: any,
    filter?: (str: string, list: any[]) => any[]
  ) : {
    suggestions: any[];
    activeIndex: number;
  };
}

export const useSuggestion: UseSuggestion = (str, list, callbackSelectSuggestion, filter = getWordsContainingString) => {
  const [activeIndex, setActiveIndex] = useState<number>(-1);

  const suggestions = useMemo(() => {
    return filter(str, list);
  }, [str, list]);

  // 上矢印キーを押したときに選択中のインデックスを変更する関数
  const handleKeydownArrowUp = useCallback(createKeydownHandler({
    key: 'ArrowUp',
    control: false,
    handler: (ev) => {
      if (suggestions.length === 0) return;
      ev.preventDefault();
      const newIndex = activeIndex - 1 >= -1 ? activeIndex -1 : suggestions.length - 1; 
      setActiveIndex(newIndex);
    }
  }), [suggestions, activeIndex, setActiveIndex]);

  // 下矢印キーを押したときに選択中のインデックスを変更する関数
  const handleKeydownArrowDown = useCallback(createKeydownHandler({
    key: 'ArrowDown',
    control: false,
    handler: (ev) => {
      if (suggestions.length === 0) return;
      ev.preventDefault();
      const newIndex = activeIndex + 1 < suggestions.length ? activeIndex + 1 : -1; 
      setActiveIndex(newIndex);
    }
  }), [suggestions, activeIndex, setActiveIndex]);
  
  // エンターを押した時に引数で渡されたコールバック関数を実行する処理
  const handleKeydownEnter = useCallback(createKeydownHandler({
    key: 'Enter',
    control: false,
    handler: (ev) => {
      if (activeIndex === -1 || suggestions.length === 0) return;
      ev.preventDefault();
      callbackSelectSuggestion(suggestions[activeIndex]);
    }
  }), [suggestions, callbackSelectSuggestion, activeIndex]);
  
  // keydownイベント登録関数を一つにまとめる
  const handleKeydown = useCallback((ev:  KeyboardEvent) => {
    handleKeydownArrowUp(ev);
    handleKeydownArrowDown(ev);
    handleKeydownEnter(ev);
  }, [handleKeydownArrowUp, handleKeydownArrowDown, handleKeydownEnter]);
  
  // keydownイベント登録と解除
  useEffect(() => {
    window.addEventListener('keydown', handleKeydown);
    return () => { window.removeEventListener('keydown', handleKeydown)};
  }, [handleKeydown]);

  return { suggestions, activeIndex };
}

export default useSuggestion
```

<h2 id="getWordsContainingString">文字列から入力候補を検索するフィルター関数<br>getWordsContainingString</h2>
検索する文字列と単語リストを引数に受け取り、検索結果を配列で返す関数です。
ひらがな、カタカナ関係なく検索できるように一文字ずつひらがなとカタカナに変換して正規表現に加えています。また、ローマ字の小文字と大文字も同様に変換して正規表現に加えています。

```typescript:getWordsContainingString.ts
interface Converter {
    (str: string): string;
}

const kana2hira = (str:string): string => {
    return str.replace(/[\u30a1-\u30f6]/g, (match: string):string => {
        const chr = match.charCodeAt(0) - 0x60;
        return String.fromCharCode(chr);
    })
}

const hira2kana = (str: string): string => {
    return str.replace(/[\u3041-\u3096]/g, (match: string) => {
        const chr = match.charCodeAt(0) + 0x60;
        return String.fromCharCode(chr);
    })
}

const lower2upper = (str: string): string => {
    return str.toUpperCase();
}

const upper2lower = (str: string): string => {
    return str.toLowerCase();
}

// 検索文字列から正規表現を作成する関数。
// ひらがな・カタカナ対しても小文字・大文字への変換をしている（ひらがな・カタカナのまま）ので、
// 一文字に対して4パターン作成されます。
// 例）「あ」の場合、
// ひらがな変換 →「あ」 カタカナ変換 → 「ア」 小文字変換 →「あ」 大文字変換 →「あ」
// あい　-> ^[あアああ][いイいい].$ 
// abc  -> ^[aaaA][bbbB][cccC].$

const createSearchPattern = (str: string, converters: Converter[]):string => {
    let convertedWords:string[] = converters.map((converter):string => {
        return converter(str);
    });
    let pattern:string = '^';
    for (let i = 0; i < str.length; i++) {
        pattern += '[';
        for (let j = 0; j < convertedWords.length; j++){
            pattern +=  convertedWords[j][i];
        }
        pattern += ']';
    }
    pattern += '.*$';
    return pattern;
}

const getWordsContainingString = (str: string, words: string[]): string[] => {
    if (str === '') return [];
    const converters = [hira2kana, kana2hira, upper2lower, lower2upper];
    const pattern = createSearchPattern(str, converters);
    const reg = new RegExp(pattern);
    const hits = words.filter(word => {
        return word.match(reg);
    });

    return hits;
}
export default getWordsContainingString;
```

<h2 id="createKeydownHandler">イベント用関数を生成するヘルパー関数<br>createKeydownHandler</h2>
引数にキーボード押下時のキー（Shift、Enterなど）、登録する関数、Ctrlキー押下を判定に加えるかの設定を渡すことで、
任意のキー押下時のみ処理される関数を作ることができます。

```typescript:createKeydownHandler.ts
interface Option {
  key: string;
  handler: (ev: KeyboardEvent) => unknown;
  control?: boolean;
}

const createKeydownHandler = (option: Option) => {
  return (ev: KeyboardEvent): any =>  {
    if (ev.key === option.key  && ev.ctrlKey === !!option.control) {
      return option.handler(ev);
    }
  }
}

export default createKeydownHandler;
```

#おわりに
最後までご覧いただきありがとうございます。
なるべく再利用可能なコードになるように心がけましたが、
React、typescriptはまだまだ実務未経験のレベルなので、もっとこうしたほうがいいなどあればコメントいただければ幸いです。