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

はじめに

「TailwindCSSで階層型のホバーメニューを実現する」の2回目です。
今回は実際にPhoenixプロジェクトを作成して、その中から使ってみます。
メニューについては、HTMLで直接構築するのではなく、階層型のデータをメニューに変換する形で実現します。

今から作るもの

このような、いろいろなサイトで見かける「マウスのホバーに反応して表示されるメニュー」を作ります。

hoverable_ddm

ソースはGithubに置いてあります。
動作するデモは Heroku Renderに置きました。

手順

以下の手順に従って進めてゆきます。

  • Phoenix プロジェクトの作成
  • TailwindCSS のセットアップ
  • TailwindCSS 用プラグインの作成と組み込み
  • Phoenix 側の実装

phoenixプロジェクトの作成

まずはプロジェクトを作成します。
DBは使用しないので、--no-ecto のオプションをつけます。

$ mix phx.new hoverable_ddm --no-ecto

バージョン情報

今回のプロジェクトで使用したそれぞれのバージョンは以下です。

項目バージョン
erlang22.3
elixir1.10.2
Phoenix1.4.16

TailwindCSSのセットアップ

作成された Phoenix プロジェクトで、TailwindCSS を使用できるようにセットアップしていきます。

各種モジュールのインストール

プロジェクトの作成が完了したら、プロジェクトに移動します。

$ cd hoverable_ddm

tailwindcssやその他カスタムプラグインの動作に必要なモジュールをインストールします。

$ npm i -D tailwindcss postcss-loader postcss-selector-parser lodash --prefix assets

Webpackの定義ファイルへ記述の追加

webpack.config.js には、CSSファイルで最初に postcss-loader を読み込むように設定します。
※逆順に読み込むので、最後に記述しています。

// assets/webpack.config.js
module: {
  rules: [
      :
    {
      test: /\.css$/,
      use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
    }
  ]
},

postcss用定義ファイルの作成

以下の位置に postcss.config.js を作成します。

└── assets
    └── postcss.config.js

postcss.config.js では、TailwindCSS公式サイトで紹介されている通りに記述します。

// assets/postcss.config.js
module.exports = {
  plugins: [
    require('tailwindcss'),
    require('autoprefixer')
  ]
};

app.cssの修正

app.css では、もともとの記述は削除して TailwindCSS の記述のみにします。

/* assets/css/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

TailwindCSS用プラグインの作成と組み込み

tailwind.config.js では、前回の記事で紹介したように記述します。

他のプロジェクトでも容易に使えるように、それぞれのプラグインを別のファイルに切り出します。
以下のハイライトされた2つのファイルを作成します。

└── assets
    └── js
        ├── addGroupHoverVariant.js        └── addSpacingUtility.js

addGroupHoverVariantの作成

カスタムな group-hover を実現するプラグインを作成します。
実装は前回の記事と同様以下のようになります。

// assets/js/addGroupHoverVariant.js
'use strict';

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

module.exports = function (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)
            })
        })
    });
}

addSpacingUtilityの作成

left-32 等のカスタムな spacing を実現するプラグインを作成します。
実装は前回の記事と同様以下のようになります。

// assets/js/addSpacingUtility.js
'use strict';

const plugin = require('tailwindcss/plugin');
const _ = require('lodash');

module.exports  =  function ( 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);
    });
}

Tailwind用定義ファイルの作成

assetsディレクトリに移動し、TailwindCSS用の定義ファイルを以下のコマンドにより作成します。

$ cd assets && npx tailwind init

tailwind.config.js が作成されます。

tailwind.config.jsへの記述

作成された tailwind.config.js に目的の記述を追加します。
ハイライトされた箇所が追加したところです。

// assets/tailwind.config.js
const  addGroupHoverVariant  = require('./js/addGroupHoverVariant');const  addSpacingUtility  = require('./js/addSpacingUtility');
module.exports = {
    theme: {
        extend: {},
    },
    variants: {
        visibility: ['responsive', 'hover', 'focus', 'active', 'group1-hover', 'group2-hover', 'group3-hover']    },
    plugins: [
        addGroupHoverVariant('group1'),        addGroupHoverVariant('group2'),        addGroupHoverVariant('group3'),        addSpacingUtility('left'),        addSpacingUtility('right'),    ],
};

追加した箇所は、3パートに別れています。

  • プラグインのインポート
  • variantsへの記述
  • pluginsへの記述
プラグインのインポート

作成したプラグインを読み込みそれぞれの変数に割り当てています。

variantsへの記述

もともとの group-hover は使用せず、今回のプラグインで対応する以下を使用します。

group-hover名内容
group1-hover第一階層に使用
group2-hover第二階層に使用
group3-hover第三階層に使用

上記group-hover名は階層の深さに対応して自動的に生成することを想定しています。
今回は第三階層までという事にします。

pluginsへの記述

読み込んだプラグインを使用しています。
addGroupHoverVariant では、variantsで追加した記述に対応した記述になっています。
addSpacingUtility では、leftright を指定しています。
本記事で使用するのは left のみですが、実験のために right も入れています。

その他設定

その他必要な設定を行います。

メニュー中に指定する振る舞いの共通化

TailwindCSS@apply を用いてメニューに関する共通の振る舞いを指定します。
このことで、プログラム中に指定するクラスの量が減ります。
@apply は以下に説明があります。

https://tailwindcss.com/docs/functions-and-directives/#apply

これらの記述は @tailwind components;@tailwind utilities; の間に行う必要があります。
以下では追加した箇所をハイライトで示しています。

/* assets/css/app.css */
@tailwind base;
@tailwind components;

.menu-item {    @apply list-none cursor-pointer px-4 py-2;}.menu-item:hover {    @apply bg-indigo-400 text-gray-200;}.menu-item-container {    @apply absolute invisible bg-gray-300 text-gray-700;}.menu-title {    @apply py-2 px-4 cursor-pointer;}.menu-group {    @apply relative px-2 rounded-sm;}.menu-group:hover {    @apply bg-indigo-400 text-gray-200;}
@tailwind utilities;

Font Awesomeのインストール

フォントアイコンを使用したいので、無料版のFontAwesomeをプロジェクトルートよりインストールします。

$ npm install --save @fortawesome/fontawesome-free --prefix assets

フォントは、app.js から以下のようにして読み込みます。

// assets/js/app.js
import '@fortawesome/fontawesome-free/js/fontawesome.min';
import '@fortawesome/fontawesome-free/js/solid.min';

Phoenix側の実装

Phoenix 側の実装に入ります。
メニューに関しては、ハードコーディングではなくメニューデータから動的に生成するようにします。

メニューデータについて

まずはメニューデータを定義します。

メニューアイテムの構造

メニューアイテムの構造を以下のように定義します。

項目内容
titleメニュー名
linkメニューが押下されたときのリンク
child_itemsサブメニューがあるときはサブメニューのリスト
ない場合は空のリスト
defmodule MenuItem do
  defstruct title: "item", link: "#", child_items: []
end

%MenuItem{}を用いたメニューデータの作成

今回は以下のように定義しました。
3階層になっています。
以下では3階層目をハイライトしています。

[
  %MenuItem{title: "item11"},
  %MenuItem{title: "item12"},
  %MenuItem{title: "item13"},
  %MenuItem{
    title: "item14",
    child_items: [
      %MenuItem{title: "item21"},
      %MenuItem{
        title: "item22",
        child_items: [          %MenuItem{title: "item31"},          %MenuItem{title: "item32"},          %MenuItem{title: "item33"},        ]      },
      %MenuItem{title: "item23"},
      %MenuItem{title: "item24"},
    ]
  },
]

メニューデータ用ファイルの作成と実装

以下の場所にmenu.exを作ります。

└── lib
    └── hoverable_ddm
        └── menu.ex

先程の %MenuItem{} とメニューデータを記述します。
get_menu/0 によりメニューデータを取得できるようにしておきます。

# lib/hoverable_ddm/menu.ex
defmodule HoverableDdm.Menu do

  defmodule MenuItem do
    defstruct title: "item", link: "#", child_items: []
  end

  def get_menu() do
    [
      %MenuItem{title: "item11"},
      %MenuItem{title: "item12"},
      %MenuItem{title: "item13"},
      %MenuItem{
        title: "item14",
        child_items: [
          %MenuItem{title: "item21"},
          %MenuItem{
            title: "item22",
            child_items: [
              %MenuItem{title: "item31"},
              %MenuItem{title: "item32"},
              %MenuItem{title: "item33"},
            ]
          },
          %MenuItem{title: "item23"},
          %MenuItem{title: "item24"},
        ]
      },
    ]
  end

Viewの構成について

以下のような構成でViewを作成します。

hoverable_ddm

階層構造で表すと以下のようになります。

LayoutView
├── NavBarView
│   └── HoverableMenuView
└── PageView

この構造に従って実装していきます。

LayoutView

app.html.eex より、NavBarView を呼び出します。
また、PageView 側では Router での指定のものが選択されます。
ルートが指定されると、 PageView が選択されます。

# lib/hoverable_ddm_web/templates/layout/app.html.eex
<body>
  <%= render HoverableDdmWeb.NavBarView, "default.html", assigns %>  <main role="main" >
    <%= render @view_module, @view_template, assigns %>  </main>
  <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>

NavBarView

以下の場所にそれぞれのファイルを作成します。

└── lib
    └── hoverable_ddm_web
        ├── templates
        │   └── nav_bar
        │       └── default.html.eex        └── views
            └── nav_bar_view.ex
default.html.eex

ファイルを作成し、以下のように記述しました。

# lib/hoverable_ddm_web/templates/nav_bar/default.html.eex
<nav class="bg-indigo-600 text-gray-200 text-bold py-2 px-6 justify-between flex items-center min-w-full">
  <div>Logo</div>
  <%= Phoenix.View.render HoverableDdmWeb.HoverableMenuView, "default.html", %{items: HoverableDdm.Menu.get_menu() } %>
  <div><%= fa_icon("fas fa-bars") %></div>
</nav>

ポイントとしては、以下です。

  • nav 要素以下では3つの子要素を定義
  • クラス指定 flex justify-between により要素を左右と中央に配置
  • 中央の要素にドロップダウンメニューを指定
  • ドロップダウンメニューとして HoverableMenuView をレンダリング
  • HoverableMenu のデータには作成したメニューデータを指定
nav

NavBarView は、以下のように記述します。

defmodule HoverableDdmWeb.NavBarView do
  use HoverableDdmWeb, :view
end

HoverableMenuView

ここからが、本記事メインの部分になります。
メニューを構成していきます。
まずは、以下の場所にファイルを作成します。

└── lib
    └── hoverable_ddm_web
        ├── templates
        │   └── hoverable_menu
        │       └── default.html.eex        └── views
            ├── hoverable_menu_view.ex            └── html_helpers.ex
html_helpers.ex

メニューはテンプレートからではなく関数にて構築していきます。
実装をわかりやすくするため単純なヘルパー関数を定義します。
fa_iconではFontAwesomeのアイコン要素を作成します。

defmodule HoverableDdmWeb.HtmlHelpers do
  use Phoenix.HTML

  def div_element(content, option) do
    content_tag(:div, content, option)
  end

  def ul_element(content, option) do
    content_tag(:ul, content, option)
  end

  def li_element(content, option) do
    content_tag(:li, content, option)
  end

  def span_element(content, option) do
    content_tag(:span, content, option)
  end

  def fa_icon(icon, opts \\ "") do
    ~E"""
    <i class="<%= icon %> <%= opts %>"></i>
    """
  end
end
default.html.eex

ここでは Viewrender_items/3を呼んでいます。
items には親から指定された @items を、title には固定で Menu を、depth にはルート階層なので 1 を指定しています。

<%= render_items( @items, "Menu", 1) %>
hoverable

階層型メニューを構成するためのコードがここで記述されています。

render_items/3

複数のメニューアイテム要素を作成します。

@menu_width 32
def render_items(items, title, depth) do
  menu_title = make_title(title, depth)
  menu_items = Enum.map(items, fn item -> item |> render_item(depth) end)
               |> ul_element([class: "menu-item-container w-#{@menu_width} #{position(depth)} group#{depth}-hover:visible"])

  [menu_title, menu_items]
  |> div_element([class: "menu-group group#{depth}"])
end

ルート階層の場合には、以下のような構造になります。

<div class="menu-group group1">
  <div>タイトル</div>
  <ul class="menu-item-container w-32 left-0 group1-hover:visible">
    ここにitemsが並びます
  </ul>
</div>
make_title/2

ルート階層かどうかによって処理を分けています。
ルート階層のタイトルはNavBar上に見えている要素です。
それ以外の階層ではサブメニュー表示になるので、タイトル名を左側に、三角アイコンを右側に表示しています。

def make_title(title, 1) do
  div_element(title, [class: "menu-title"])
end

def make_title(title, _depth) do
  t = div_element(title,[])
  a = fa_icon("fas fa-caret-right")
  div_element([t, a], [class: "flex items-center justify-between p-2"])
end
position/1

サブメニューの位置がabsoluteで配置されます。
ルート階層では1番目の関数が呼ばれ、親要素と左側がピッタリ合った配置になります。
ルート階層以外では2番目の関数が呼ばれ、親要素の幅だけ右にずれ上端がピッタリ合う配置になります。

def position(1) do
  "left-0"
end

def position(_depth) do
  "left-#{@menu_width} top-0"
end
render_item/2

メニュー1行分の要素を作成します。
子要素がない場合は、1番目の関数が呼ばれ通常のメニューを表示します。
子要素がある場合は、2番目の関数が呼ばれ render_items/3 によりサブメニューを構成します。
その際にdepthを1つ増やします。

def render_item(%MenuItem{title: title, link: link, child_items: []}, _depth) do
  li_element(link( title, to: link ), [class: "menu-item"])
end

def render_item(%MenuItem{title: title, child_items: child_items}, depth) do
  render_items(child_items, title, depth + 1)
end

depthの値が1つ大きくなることにより、2階層目では以下のような構造になります。

<div class="menu-group group2">  <div>タイトル</div>
  <ul class="menu-item-container w-32 left-0 group2-hover:visible">    ここにitemsが並びます
  </ul>
</div>
全体

全体は以下のようになります。

# lib/hoverable_ddm_web/views/hoverable_menu_view.ex
defmodule HoverableDdmWeb.HoverableMenuView do
  use HoverableDdmWeb, :view
  alias HoverableDdm.Menu.MenuItem
  import HoverableDdmWeb.HtmlHelpers

  @menu_width 32

  def position(1) do
    "left-0"
  end

  def position(_depth) do
    "left-#{@menu_width} top-0"
  end

  def make_title(title, 1) do
    div_element(title, [class: "menu-title"])
  end

  def make_title(title, _depth) do
    t = div_element(title,[])
    a = fa_icon("fas fa-caret-right")
    div_element([t, a], [class: "flex items-center justify-between p-2"])
  end

  def render_items(items, title, depth) do
    menu_title = make_title(title, depth)
    menu_items = Enum.map(items, fn item -> item |> render_item(depth) end)
                 |> ul_element([class: "menu-item-container w-#{@menu_width} #{position(depth)} group#{depth}-hover:visible"])

    [menu_title, menu_items]
    |> div_element([class: "menu-group group#{depth}"])
  end

  def render_item(%MenuItem{title: title, link: link, child_items: []}, _depth) do
    li_element(link( title, to: link ), [class: "menu-item"])
  end

  def render_item(%MenuItem{title: title, child_items: child_items}, depth) do
    render_items(child_items, title, depth + 1)
  end

end

これで実装は終わりです。

おわりに

前回と今回との2回にわたって、「TailwindCSSで階層型のホバーメニューを実現する」方法を紹介してきました。
group-hover については、メニューだけでなくツールチップ等にも応用が可能です。

ソースはGithubに置いてあります。
動作するデモは Heroku Renderに置きました。