🏷️

【Tailwind CSS】タグ入力用テキストボックスの作り方

に公開

はじめに

今月リリースしたサービスの中で使っているタグ入力用テキストボックスの作り方を紹介します。順序だてて作成していきますが、不要な方はコードを置いておくのでコピペしてください。

宣伝

折角興味を持って記事を開いていただいたのにすみません、宣伝させてください。

アンケートを作って投票できるサービスを作ったので遊んでみてください、投票についてはユーザ登録無しでできます!
投票だけではなく登録していただいて、アンケートを作っていただけると嬉しいです!
https://bloomsurvey.com/

🍛カレーの辛さは何派?
https://bloomsurvey.com/pot/cm7m1yu6z0001s60dtwz0rrlk

🍜ラーメンと言えば何味?
https://bloomsurvey.com/pot/cm84l2wny0003s60dwvfu5qfl

アウトプット

以下のような動作をします。

  • タグ入力後にEnterキー押下でタグ作成
  • 未入力状態のbackspaceキーorタグの✕ボタン押下でタグ削除
  • 入力中のbackspaceキーは通常通り入力している文字の削除

以下画像のような見た目、以下コードのような実装をしています。いけてない作りをしていると思うので適宜作り変えていただければと思います。

// タグリストとタグを設定するstateをもらう
type TagInputParams = {
  tagList: string[];
  parentUseState: Dispatch<SetStateAction<Array<string>>>;
};

// タグ入力用テキストボックスコンポーネント
const TagInput = (props: TagInputParams) => {
  const maxTagSize = 3;

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    // 日本語入力等で入力が確定していない状態は動作させない
    if (e.nativeEvent.isComposing) return;

    const value = e.currentTarget.value;

    // バックスペース
    // タグ入力中でなければタグを1つ消す
    if (
      e.key === "Backspace" &&
      value.length === 0 &&
      props.tagList.length > 0
    ) {
      DeleteTag(props.tagList.length - 1);
      return;
    }

    //Enter以外のキーは動作させない
    if (e.key !== "Enter" || !value.trim()) return;

    // Enterキー押下時にタグ追加
    if (props.tagList.length < maxTagSize) {
      const newTagList = [...props.tagList, value];
      props.parentUseState(newTagList);
    }
    e.currentTarget.value = "";
  };

  const deleteTag = (i: number) => {
    const newTagList = [...props.tagList];
    props.parentUseState(newTagList.filter((tag, index) => i !== index));
  };

  return (
    <div className="flex w-full flex-wrap rounded-md border p-2 outline-none has-[:focus]:border-blue-500 gap-4 divide-x-2 divide-white">
      {props.tagList.map((item, index) => {
        return (
          <Tag
            key={index}
            tagName={item}
            useInput={true}
            parentFunction={() => deleteTag(index)}
          />
        );
      })}
      <input
        placeholder="タグ(3つまで)"
        onKeyDown={(e) => {
          handleKeyDown(e);
        }}
        type="text"
        className="mr-6 grow outline-none sm:mr-0"
      />
    </div>
  );
};

export default TagInput;
// タグ名とタグを削除する処理をもらう
type TagParams = {
  tagName: string;
  parentFunction: VoidFunction;
};

// タグコンポーネント
const Tag = (props: TagParams) => {
  return (
    <div
      className="me-2 flex min-w-10 items-center justify-center rounded-lg bg-green-200 px-2.5 py-0.5 text-xs font-medium"
    >
          <span>
            {props.tagName}
          </span>
          <Image
            priority
            src={Close}
            alt="closes"
            className="size-5 cursor-pointer"
            onClick={() => props.parentFunction()}
          />
    </div>
  );
};

export default Tag;

構造

以下のような構造になっています。

  • 緑枠:タグ表示部分
  • 赤枠:テキストボックス(inputタグ)
  • 青枠:テキストボックスに見せかけた領域(以降ではダミー領域と記載します)

入力はダミー領域内のテキストボックスに対して行います。そのため、ダミー領域がテキストボックスに見えるようにスタイルを調整しています。同様にテキストボックスが内部に存在することがわからないように、テキストボックスもスタイル調整しています。
タグ作成時には緑枠にタグを表示させてその分テキストボックスを狭くします。

以降で順に実装していきます。

ダミー領域実装

ダミー領域をテキストボックスに見せかけるように実装していきます。

const TagInput = () => {
  // Tagとinputは仮置きです。後ほど実装していきます。
  return (
    <div className="flex w-full flex-wrap rounded-md border p-2 outline-none has-[:focus]:border-blue-500">
      <Tag />
      <input />
    </div>
  );
};

export default TagInput;
解説

まず通常時のアウトラインとホバー時のアウトラインをborder outline-none has-[:focus]:border-blue-500辺りのクラスで調整します。ここではテキストボックスの角を少し丸くするためにrounded-mdも適用しますが、この辺りは個々のサービスで調整してください。

タグ表示部分とテキストボックスを横並びにするためにflexを適用します。ここではダミー領域が画面横幅いっぱいになるようにw-fullを適用していますが、ここも必要に応じて設定してください。また、タグの内容によっては(特にモバイルから操作したときに)表示領域を超過することが考えられます。そのため折り返すようにflex-wrapも設定します。

最後にp-2を設定して、入力するテキストとダミー領域ボーダーの間隔を調整します。

テキストボックス実装

ダミー領域実装でテキストボックスを仮実装しましたが、このままでは2つ問題点があるので修正します。
1つ目がフォーカス時にテキストボックスのボーダーが表示されてしまうこと。2つ目が横幅が足りなくてフォーカスできる領域が限られていることです。それを解消したコードが以下です。

const TagInput = () => {
  // Tagとinputは仮置きです。後ほど実装していきます。
  return (
    <div className="flex w-full flex-wrap rounded-md border p-2 outline-none has-[:focus]:border-blue-500">
      <Tag />
      <input
        placeholder="タグ"
        type="text"
        className="mr-6 grow outline-none sm:mr-0"
      />
    </div>
  );
};

export default TagInput;
解説

ボーダーはoutline-noneを適用することで消すことができます。横幅はgrowを適用することで伸ばすことができます。その他にmr-6 sm:mr-0を設定しています。画面サイズが小さいときに、テキストボックスがダミー領域を突き抜けないようにするためのクラス設定です。

見た目はそれらしくなりました。
最後にタグ表示部分とタグ作成に必要な処理を実装していきます。

タグ入力部分

タグそのものと、タグを追加削除する処理を順に実装していきます。

タグ実装

タグ名とタグを削除する「×」が欲しいのでこれらを実装します(画像はMaterial IconsからDLして使っていました)。

// タグコンポーネント
const Tag = () => {
  return (
    <div
      className="me-2 flex min-w-10 items-center justify-center rounded-lg bg-green-200 px-2.5 py-0.5 text-xs font-medium gap-4 divide-x-2 divide-white"
    >
      <span>
        タグ名
      </span>
      <Image
        priority
        src={Close}
        alt="closeIcon"
        className="size-5 cursor-pointer"
      />
    </div>
  );
};

export default Tag;
解説

タグ名と「×」の境界を作るためにgap-4 divide-x-2 divide-whiteを設定しています。他クラスについては位置や大きさを調整するクラスがほとんどであるため省略します。

処理実装

次にタグを追加する処理と削除する処理を実装します。これは親であるダミー領域のコンポーネントに実装します。また、レンダリングのためmap展開まで行います。リストやリストを更新するsetterは親から渡されるものとします。

// タグリストとタグを設定するstateをもらう
type TagInputParams = {
  tagList: string[];
  parentUseState: Dispatch<SetStateAction<Array<string>>>;
};

// タグ入力用テキストボックスコンポーネント
const TagInput = (props: TagInputParams) => {
  const maxTagSize = 3;

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    // 日本語入力等で入力が確定していない状態は動作させない
    if (e.nativeEvent.isComposing) return;

    const value = e.currentTarget.value;

    // バックスペース
    // タグ入力中でなければタグを1つ消す
    if (
      e.key === "Backspace" &&
      value.length === 0 &&
      props.tagList.length > 0
    ) {
      DeleteTag(props.tagList.length - 1);
      return;
    }

    //Enter以外のキーは動作させない
    if (e.key !== "Enter" || !value.trim()) return;

    // Enterキー押下時にタグ追加
    if (props.tagList.length < maxTagSize) {
      const newTagList = [...props.tagList, value];
      props.parentUseState(newTagList);
    }
    e.currentTarget.value = "";
  };

  const deleteTag = (i: number) => {
    const newTagList = [...props.tagList];
    props.parentUseState(newTagList.filter((tag, index) => i !== index));
  };

  return (
    <div className="flex w-full flex-wrap rounded-md border p-2 outline-none has-[:focus]:border-blue-500 gap-4 divide-x-2 divide-white">
      {props.tagList.map((item, index) => {
        return (
          <Tag
            key={index}
            tagName={item}
            useInput={true}
            parentFunction={() => deleteTag(index)}
          />
        );
      })}
      <input
        placeholder="タグ(3つまで)"
        onKeyDown={(e) => {
          handleKeyDown(e);
        }}
        type="text"
        className="mr-6 grow outline-none sm:mr-0"
      />
    </div>
  );
};

export default TagInput;
解説

テキストボックスにキー押下イベントを設定して、キーごとにイベントを生成しています。「アウトプット」のところでも記載済ですが以下を満たすように実装しています。

  • タグ入力後にEnterキー押下でタグ作成
  • 未入力状態のbackspaceキーorタグの✕ボタン押下でタグ削除
  • 入力中のbackspaceキーは通常通り入力している文字の削除

難しい処理はないと思いますが、タグリストの更新部分について補足させてください。コメントでも書いている通りsetterを親から受け取ってそれを使ってリストを更新しています。おそらくいけてない書き方だと思うので書き換えて使っていただければと思います。

最後にタグの「×」をクリックしたときにタグ削除が行われるようにタグコンポーネントにも修正を加えます。

// タグ名とタグを削除する処理をもらう
type TagParams = {
  tagName: string;
  parentFunction: VoidFunction;
};

// タグコンポーネント
const Tag = (props: TagParams) => {
  return (
    <div
      className="me-2 flex min-w-10 items-center justify-center rounded-lg bg-green-200 px-2.5 py-0.5 text-xs font-medium"
    >
      <span>
        {props.tagName}
      </span>
      <Image
        priority
        src={Close}
        alt="closeIcon"
        className="size-5 cursor-pointer"
        onClick={() => props.parentFunction()}
      />
    </div>
    </div>
  );
};

export default Tag;
解説

こちらも親から関数を受け取ります。「×」をクリックしたときそのイベントを発火させるようにして、同じく受け取ったタグ名を表示させるようにしただけです。

おわりに

最初はどうやって作っているのか全くイメージできませんでしたが、構造が理解できてしまうと特段難しいことはありませんでした。同じ仕組みで検索用テキストボックス(テキストボックスの左右どちらかに虫眼鏡があるようなやつ)も作ることができます。

この記事の内容が少しでも参考になれば幸いです。

Discussion