TailwindCSSで階層型のホバーメニューを実現する

はじめに

TailwindCSSのみを使用したホバータイプのドロップダウンメニューを作成します。
以前の記事では、クリックによって表示非表示が切り替わるドロップダウンメニューを紹介しました。

今回は、CSSのみで実現するホバーメニューに対して以下の点に焦点をあてて記述していきます。

  • 最小構成での実現方法の確認
  • TailwindCSSではどう実現するか
  • 階層型サブメニューがある場合はどうするか
  • 必要な機能を追加するためにプラグインはどうやって作成するか

主な手順

以下の手順で記述します。

  • 原理試作
  • プラグインの作成
  • これらを使ってPhoenixでの階層型メニューの作成(次回)

環境

環境に関しては、TailwindCSSのみを使用し以下のようになっています。

項目バージョン
TailwindCSS1.2.0

原理試作

まず素のCSSで確認を行い、その後TailwindCSSでどう実現するかという段取りで進めます。

素のCSSで実現方法の確認

CSSのみで実現するドロップダウンメニューの実現方法として、最小限の実装を通して確認します。
実現方法の確認なので、要素の見た目は度外視です。
まず、以下のように要素を作ります。
簡単のため、body の下には全部 div 要素を配置することにします。

<body>
  <div class="dropdown">
    <div>
      Menu
    </div>
    <div class="menu">
      <div>Item1</div>
      <div>Item2</div>
      <div>Item3</div>
    </div>
  </div>
</body>

ここで .menu 以下の要素は通常は表示せず、Menu がホバーされたときのみ表示されるようにします。
CSSは以下のようになります。

.dropdown {
  position: relative;
}

.menu {
  position: absolute;
  visibility: hidden;
}

.dropdown:hover .menu {  visibility: visible;}

上記でハイライトされている箇所がポイントです。
親要素が hover されているときに、.menu が指定された子要素が表示されます。
動作が確認できるデモを CodePen に置きました。
以下から確認できます。
ここでは、わかりやすさのため背景色を指定しています。

TailwindCSSでの実現方法

TailwindCSS では、上記を実現するのにgroup-hoverを使います。
group-hoverに関する説明は、本家サイトの以下の部分に記述があります。

https://tailwindcss.com/docs/pseudo-class-variants/#group-hover

TailwindCSS では、上記を指定するのにちょっと細工が必要になります。

まず、tailwind.config.js が存在していなければ、作成します。
TailwindCSSがプロジェクトにインストールされている場合は、以下のコマンドで作成できます。

$ npx tailwind init

作成されたファイルに対して、以下のハイライト部分を追加します。
デフォルトの出力に加えて最後尾に group-hover を追加しています。
今回は表示状態を変更したいので、visibility に対してのみ指定しています。

// tailwind.config.js
module.exports = {
  theme: {
    extend: {},
  },
  variants: {
    visibility: ['responsive', 'hover', 'focus', 'group-hover']  },
  plugins: [],
}

上記を指定し、コマンドを通じてCSSを生成すると、以下のクラスが自動生成されます。

.group:hover .group-hover\:visible {
  visibility: visible;
}

.group:hover .group-hover\:invisible {
  visibility: hidden;
}

実際は以下のようにsm md等のレスポンシブな指定が含まれたものも出力します。

.group:hover .sm\:group-hover\:visible {
    visibility: visible;
  }

これらを踏まえて、素のCSSで指定したものと等価なものは、TailwindCSSでは以下のようになります。

<body>
  <div class="relative group bg-red-100">
    <div>
      Menu
    </div>
    <div class="absolute invisible group-hover:visible bg-green-100">
      <div>Item1</div>
      <div>Item2</div>
      <div>Item3</div>
    </div>
  </div>
</body>

素のCSSとの対応は以下のようになります。

項目素のCSSTailwindCSS(ホバーで表示する場合)
親要素.dropdown.group
子要素.menu.group-hover:visible

ここまでのものを CodePen に置きました。

ただし、CodePen の仕様上 TailwindCSS はデフォルトのものをリンクすることしかできません。
そのため、生成されたものと等価なものを CodePenCSS に貼り付けています。

サブメニューへの対応

サブメニューへの対応ですが、基本的には今までメニューを構築したものと同じになります。
今まで試作していたものに対して、メニュー部分全てをコピーし Item2 の要素と置き換えます。
インデントを調整すると以下のようになります。
ハイライトされた部分が、置き換わったところです。

<body>
  <div class="relative group bg-red-100">
    <div>
      Menu
    </div>
    <div class="absolute invisible group-hover:visible bg-green-100">
      <div>Item1</div>
      <div class="relative group bg-red-100">        <div>          Menu        </div>        <div class="absolute invisible group-hover:visible bg-green-100">          <div>Item1</div>          <div>Item2</div>          <div>Item3</div>        </div>      </div>      <div>Item3</div>
    </div>
  </div>
</body>

ただし、これだけでは以下の問題があります。

  1. Menuをホバーさせただけで、サブメニューも表示されてしまう
  2. サブメニューのアイテムが下に表示されてしまう

これらの問題を解決します。

Menuをホバーさせただけで、サブメニューも表示されてしまう

原因は、メニューに指定している groupgroup-hover を、サブメニューにも指定しているところにあります。
この問題を解決するため、これらを別のクラス名にして区別するようにします。
そのため、サブメニューの groupgroup2 に変更します。
現在は groupgroup2 だけですが、実際にはサブメニューの階層分必要になります。
残念なことに、現時点でのTailwindCSS(Ver.1.2.0)には group-hover に対して別名をつけるというこの機能はありません。
そのため、プラグインを自作することになります。
今回は原理試作なので、CSSに以下のクラスを置いて対応することにします。
group2 を使用した記述は、以下のようになります。

.group2:hover .group2-hover\:visible {
  visibility: visible;
}

これに従って、対応するHTMLには以下の記述に変更します。

<div class="relative group2 bg-red-100">  <div>
    Menu
  </div>
  <div class="absolute invisible group2-hover:visible bg-green-100">    :

サブメニューのアイテムが下に表示されてしまう

一般的なサブメニューでは、アイテムはホバーされている要素の下ではなく右または左に表示されます。
現状は何も指定していないので、下に表示されています。
これを、現在のメニューの幅だけ右側に表示するようにします。
サブメニューの移動量を決めるために、メニューの幅を指定するように変更します。
今回は、メニューの幅を .w-20 とします。
つまり、サブメニューは .w-20 分だけを右に移動する必要があります。
これは、TailwindCSSの記述ルールでいくと .left-20 と指定することが想定される場面になります。
ところが、.left-20TailwindCSS には存在しません。
そのため、これもプラグインで作り出すことになります。
また、高さ方向はホバーされている要素と合わせるため .top-0 を指定します。
これらを踏まえると、指定は以下のようになります。

<div class="absolute w-20 top-0 left-20 invisible group2-hover:visible bg-green-100">
  :

原理試作の段階では、CSSに .left-20 を別途定義することで対応します。
20に割り当てられている数値ですが、本家のサイトの以下の部分に記載されています。

https://tailwindcss.com/docs/customizing-spacing#default-spacing-scale

ということで、left-20 は以下のようになります。

.left-20 {
  left: 5rem;
}

ここまでのものを CodePen に置きました。

プラグインの作成

目的のクラスを生成するために、プラグインを作成します。
プラグインの作成については、以下を参考にしました。

https://tailwindcss.com/docs/plugins

カスタムgroup-hoverプラグイン

デフォルトの group-hover を生成しているソースコードが以下にあります。

https://github.com/tailwindcss/tailwindcss/blob/master/src/lib/substituteVariantsAtRules.js

これを参考に、若干の仕様変更を入れながらプラグインを実装します。
関数内で使用しているモジュールで必要なものをインストールします。

$ npm i -D postcss-selector-parser

関数のパラメータは以下のようにします。

パラメータ名内容
hoverNamegroup,group-hovergroup 部分に相当する文字列を指定
combinator2つのセレクタの結合子を指定

結合子は以下のものに対応します。

結合子記号内容
子孫結合子空白文字(デフォルト)1つ目のセレクターに一致する要素の子孫のうち、2つ目のセレクターに一致する要素を選択
隣接兄弟結合子+同じ親要素の子同士であって、1つ目の要素の直後にある2つ目の要素を選択
一般兄弟結合子~同じ親要素の子同士であって、1つ目の要素の後にある2つ目の要素を選択
子結合子>2つ目のセレクターが1つ目のセレクターの子要素の場合にのみマッチ

上記を踏まえ、プラグインの実装は以下のようになりました。

const plugin = require('tailwindcss/plugin');
const selectorParser = require('postcss-selector-parser');
const prefixSelector = require('tailwindcss/lib/util/prefixSelector.js').default;

function _addGroupHoverVariant(hoverName, combinator = " ") {
  if(!combinator.match(/^[>+~ ]$/)) {
    throw `Invalid combinator. Your argument '${combinator}' is not acceptable.`
  }
  combinator = combinator.replace(/([>+~])/," $1 ");
  return plugin(function({ addVariant, config  }) {
    addVariant(`${hoverName}-hover`, ({ modifySelectors, separator }) => {
      return modifySelectors(({ selector }) => {
        return selectorParser(selectors => {
          selectors.walkClasses(sel => {
            sel.value = `${hoverName}-hover${separator}${sel.value}`;
            sel.parent.insertBefore(sel, selectorParser().astSync(prefixSelector(config('prefix'), `.${hoverName}:hover${combinator}`))
            )
          })
        }).processSync(selector)
      })
    })
  });
}

指定するのが group group2だけの場合、使用方法は以下のようになります。

// tailwind.config.js
module.exports = {
  theme: {
    extend: {},
  },
  variants: {
    visibility: ['responsive', 'hover', 'focus', 'active', 'group-hover', 'group2-hover']
  },
  plugins: [
    _addGroupHoverVariant('group2'),
  ],
};

カスタムspacing

spacingの値は、Theme Configurationから取得できます。
以下を参照すると、theme.spacingに目的のものが入っています。

https://tailwindcss.com/docs/theme/#spacing

上記の各値に対してクラスを出力するようにします。
ということでmap関数を使用したいので、まずはlodashをインストールします。

npm i -D lodash

クラスの出力方法は以下が参考になります。 (2020.4.26追記) https://tailwindcss.com/docs/plugins#escaping-class-names

実装は以下のようになりました。

パラメータ名内容
atr_nameleft, right 等を指定
const _ = require('lodash');

function _addSpacingUtility( atr_name ) {
  return plugin(function({ addUtilities,  e, config }) {
    const newUtility = _.map(config('theme.spacing'), (value, key) => {
      return {
        [`.${e(`${atr_name}-${key}`)}`]: {
          [`${atr_name}`]: `${value}`
        }
      }
    });
    addUtilities(newUtility);
  });
}

使用方法は以下になります。
この例では、top left rightを指定しています。

// tailwind.config.js
module.exports = {
  theme: {
    extend: {},
  },
  variants: {
    :
  },
  plugins: [
    _addSpacingUtility('top'),
    _addSpacingUtility('left'),
    _addSpacingUtility('right'),
  ],
};

これで目的のものは全部作成しました。

おわりに

「CSSのみで実現するドロップダウンメニューの作成」という記事はいろいろなところで見るのですが、TailwindCSSでの実現方法が記述されているもの、特に階層メニューの実現方法について言及されているものは、ほとんどありませんでした。
ということで、今回作ってみました。
TailwindCSSの内部構造により仲良くなれたので良かったと思います。
次回はPhoenixプロジェクトを作成し、今回の成果を使用してプログラマブルに階層メニューを実現する方法を紹介します。