「TailwindCSSで階層型のホバーメニューを実現する」の2回目です。
今回は実際にPhoenixプロジェクトを作成して、その中から使ってみます。
メニューについては、HTMLで直接構築するのではなく、階層型のデータをメニューに変換する形で実現します。
このような、いろいろなサイトで見かける「マウスのホバーに反応して表示されるメニュー」を作ります。
ソースはGithubに置いてあります。
動作するデモは Heroku Renderに置きました。
以下の手順に従って進めてゆきます。
まずはプロジェクトを作成します。
DBは使用しないので、--no-ecto
のオプションをつけます。
$ mix phx.new hoverable_ddm --no-ecto
今回のプロジェクトで使用したそれぞれのバージョンは以下です。
項目 | バージョン |
---|---|
erlang | 22.3 |
elixir | 1.10.2 |
Phoenix | 1.4.16 |
作成された Phoenix プロジェクトで、TailwindCSS を使用できるようにセットアップしていきます。
プロジェクトの作成が完了したら、プロジェクトに移動します。
$ cd hoverable_ddm
tailwindcssやその他カスタムプラグインの動作に必要なモジュールをインストールします。
$ npm i -D tailwindcss postcss-loader postcss-selector-parser lodash --prefix assets
webpack.config.js
には、CSSファイルで最初に postcss-loader
を読み込むように設定します。
※逆順に読み込むので、最後に記述しています。
// assets/webpack.config.js
module: {
rules: [
:
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}
]
},
以下の位置に postcss.config.js
を作成します。
└── assets
└── postcss.config.js
postcss.config.js
では、TailwindCSS公式サイトで紹介されている通りに記述します。
// assets/postcss.config.js
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer')
]
};
app.css
では、もともとの記述は削除して TailwindCSS
の記述のみにします。
/* assets/css/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
tailwind.config.js
では、前回の記事で紹介したように記述します。
他のプロジェクトでも容易に使えるように、それぞれのプラグインを別のファイルに切り出します。
以下のハイライトされた2つのファイルを作成します。
└── assets
└── js
├── addGroupHoverVariant.js └── addSpacingUtility.js
カスタムな 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)
})
})
});
}
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);
});
}
assetsディレクトリに移動し、TailwindCSS用の定義ファイルを以下のコマンドにより作成します。
$ cd assets && npx tailwind init
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パートに別れています。
作成したプラグインを読み込みそれぞれの変数に割り当てています。
もともとの group-hover
は使用せず、今回のプラグインで対応する以下を使用します。
group-hover名 | 内容 |
---|---|
group1-hover | 第一階層に使用 |
group2-hover | 第二階層に使用 |
group3-hover | 第三階層に使用 |
上記group-hover名は階層の深さに対応して自動的に生成することを想定しています。
今回は第三階層までという事にします。
読み込んだプラグインを使用しています。
addGroupHoverVariant
では、variantsで追加した記述に対応した記述になっています。
addSpacingUtility
では、left
と right
を指定しています。
本記事で使用するのは 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;
フォントアイコンを使用したいので、無料版の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 側の実装に入ります。
メニューに関しては、ハードコーディングではなくメニューデータから動的に生成するようにします。
まずはメニューデータを定義します。
メニューアイテムの構造を以下のように定義します。
項目 | 内容 |
---|---|
title | メニュー名 |
link | メニューが押下されたときのリンク |
child_items | サブメニューがあるときはサブメニューのリスト ない場合は空のリスト |
defmodule MenuItem do
defstruct title: "item", link: "#", child_items: []
end
今回は以下のように定義しました。
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を作成します。
階層構造で表すと以下のようになります。
LayoutView
├── NavBarView
│ └── HoverableMenuView
└── PageView
この構造に従って実装していきます。
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>
以下の場所にそれぞれのファイルを作成します。
└── lib
└── hoverable_ddm_web
├── templates
│ └── nav_bar
│ └── default.html.eex └── views
└── nav_bar_view.ex
ファイルを作成し、以下のように記述しました。
# 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
のデータには作成したメニューデータを指定NavBarView
は、以下のように記述します。
defmodule HoverableDdmWeb.NavBarView do
use HoverableDdmWeb, :view
end
ここからが、本記事メインの部分になります。
メニューを構成していきます。
まずは、以下の場所にファイルを作成します。
└── lib
└── hoverable_ddm_web
├── templates
│ └── hoverable_menu
│ └── default.html.eex └── views
├── hoverable_menu_view.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
ここでは View
の render_items/3
を呼んでいます。
items
には親から指定された @items
を、title
には固定で Menu
を、depth
にはルート階層なので 1
を指定しています。
<%= render_items( @items, "Menu", 1) %>
階層型メニューを構成するためのコードがここで記述されています。
複数のメニューアイテム要素を作成します。
@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>
ルート階層かどうかによって処理を分けています。
ルート階層のタイトルは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
サブメニューの位置がabsoluteで配置されます。
ルート階層では1番目の関数が呼ばれ、親要素と左側がピッタリ合った配置になります。
ルート階層以外では2番目の関数が呼ばれ、親要素の幅だけ右にずれ上端がピッタリ合う配置になります。
def position(1) do
"left-0"
end
def position(_depth) do
"left-#{@menu_width} top-0"
end
メニュー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
については、メニューだけでなくツールチップ等にも応用が可能です。