LiveViewとTailwindCSSを用いてDropdownList付きのNavBarを構築する

はじめに

Phoenixプロジェクトに、TailwindCSSを適用した例を紹介します。
この記事では以下ついて記載しています。

  • Phoenix プロジェクトへの TailwindCSS の追加方法
  • Vue で作られた実装例を Phoenix.LiveView を用いて Phoenix プロジェクトへの適用方法

TailwindCSSについて

TailwindCSS はCSSフレームワークのひとつです。
以下に公式ページがあります。

公式ページ

このフレームワークの特徴は、以下のようなところです。

  • CSSファイルは TailwindCSS で生成されたものを使用する
  • よく使うスタイルがクラスになって記述されている
  • 開発者は要素のクラスに与えたいスタイルを追加してゆくだけ
  • クラス名に悩むこともない

例えば以下のように指定した要素があったとすると、

<div class="foo">Some element</div>
.foo {
  background-color: #edf2f7;
  color: #2d3748;
  margin: 0.5rem;
  padding: 0.5rem;
}
.foo:hover {
  background-color: #a0aec0;
}

TailwindCSS が定義したクラスを使用して、以下のようにスタイリングします。

<div class="bg-gray-200 hover:bg-gray500 text-gray-800 m-2 p-2">Some element</div>

スタイルのバリエーションは TailwindCSS で良さげな感じに規定されているので、欲しいスタイルをクラスに追加していけば良さげな感じになります。
Phoenix プロジェクトに適用できないかと思い、TailwindCSS に入門してみました。

今からつくるもの

このようなものを作ります。

tailwindcss__and_liveview

これは、TailwindCSSの公式ページにある以下のスクリーンキャストを見て実装しました。

BUILDING A RESPONSIVE NAVBAR

  1. Building a Navbar Layout with Flexbox
  2. Toggling the Navbar Links on Mobile
  3. Making the Navbar Responsive

BUILDING A DROPDOWN MENU

  1. Styling the Basic Dropdown Elements
  2. Positioning the Dropdown Area
  3. Making the Dropdown Interactive
  4. Adapting the Dropdown for Mobile

これらのスクリーンキャストでは、Vueを使用して実装しています。
この Vue で作られている部分を今回 Phoenix.LiveView で実装し直しました。
プロジェクト内で使用されている画像については、こちらで用意しました。

以下の要素が含まれています。

  • NavBarの構築方法
  • NavBarをレスポンシブに対応する方法
  • ドロップダウンリストの作成方法

もともとのソースコード自体は、以下にあります。

今回作成したソースコードは以下にあります。

TlwndDdm

バージョン情報

記事公開時点でのそれぞれのバージョンは以下です。

項目バージョン
erlang22.3
elixir1.10.2-otp-22
Phoenix1.4.16
Phoenix.LliveView0.8.0

※ 諸事情により、言語系のパッケージマネージャーは Homebrew から asdf に変更しました。

手順

以下の手順で、構築していきます。

  1. Phoenixプロジェクトの作成
  2. Phoenix.LiveViewの組み込み
  3. TailwindCSSの組み込み
  4. VueプロジェクトをPhoenixプロジェクトへ変換

Phoenixプロジェクトの作成

例によってプロジェクトの作成から行います。
DBを使用しないので、 --no-ecto オプションをつけます。

$ mix phx.new tlwind_ddm --no-ecto

Phoenix.LiveViewの組み込み

Phoenix.LiveViewを組み込みます。
公式のページに従って組み込んでいきます。
Phoenix 1.4.16 を使用している場合は以下の手順が不要になりました。

上記サイトでは LiveView のバージョンは0.10.0でしたが、記事公開時にセットアップがうまく行かなかったので0.8.0を用いています。

Update: バージョン0.10を用いた最新のインストール方法は、Phoenix.LiveViewの最新インストール方法で紹介しています。

  • config.exssigning_salt を指定するところ
  • endpoint.ex@session_options を作るところ
  • app.html.eex<%= csrf_meta_tag() %> を追加するところ

TailwindCSSの組み込み

以下のページを参考に組み込みました。

Adding Tailwind CSS to Phoenix 1.4

手順は以下のようになります。

  • TailwindCSS 及び postcss-loader のインストール
  • postcss.config.js ファイルの作成
  • Webpackpostcss-loader を使用するように指定
  • app.cssTailwindCSS 読み込みの記述

カスタマイズ等の設定は、今回は必要ないので行わないことにします。

TailwindCSS及びpostcss-loaderのインストール

tailwindcsspostcss-loader をインストールします。
以下のコマンドは、プロジェクトのルートで行っています。

# @Project root
$ npm i -D tailwindcss postcss-loader --prefix assets

postcss.config.jsファイルの作成

以下のように postcss.config.js を作成します。

├── assets
│   ├── css
│   │   └── app.css
│   ├── js
│   │   └── app.js
│   ├── package.json
│   ├── postcss.config.js│   └── webpack.config.js

上記ファイルでの記述は以下のようにします。

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

Webpackへpostcss-loaderを使用するように指定

css ファイルに対して、最初に postcss-loader を通すように設定します。
Webpackでは逆順に読まれるので、以下のように最後に記述します。

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

app.cssへTailwindCSSの記述

もともとの記述は必要ないので全て削除し、新たに以下の記述を追加します。

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

VueプロジェクトをPhoenixプロジェクトへ変換

Vue で記述されたコードを Phoenix に変換していきます。
Vue の文法等には特に立ち入らず、Phoenix.LiveView で実現できるところに焦点をあてて作業を行っていきます。
各変換方法は、初出のところにポイントを絞って説明します。

App.vue

コンポーネントとしてのエントリポイントである App.vue からPhoenix化していきます。

NavBarコンポーネントのレンダリング

コンポーネントのレンダリングに対してそれぞれの違いを認識しながら変換します。

Vue

以下の Vue ファイルでは Navbar というコンポーネントをレンダリングしています。

<!-- src/App.vue -->
<template>
  <div id="app" class="antialiased text-gray-900">
    <div class="bg-gray-200 min-h-screen">
      <Navbar/>    </div>
  </div>
</template>
<script>
import Navbar from './components/Navbar'export default {
  name: 'app',
  components: {    Navbar  },}
</script>

<style src="./assets/tailwind.css"></style>
Phoenix.LiveView

Phoenix の場合は、以下のハイライト箇所のように live_render でレンダリングします。

# lib/tlwnd_ddm_web/templates/layout/app.html.eex
<div id="app" class="antialiased text-gray-900">
  <div class="bg-gray-300" >
    <%= live_render @conn, TlwndDdmWeb.NavBar %>  </div>
  <main role="main" class="container">
    <%= render @view_module, @view_template, assigns %>
  </main>
</div>

NavBar.vue

次に、先程レンダリングした NavBar コンポーネントです。

コンポーネントで使用するデータの初期化

Vue

以下のように isOpen が初期化されています。

<!-- src/components/NavBar.vue -->
<script>
import AccountDropdown from './AccountDropdown'
export default {
  :
  data() {
    return {
      isOpen: false,    }
  },
  :
</script>
Phoenix.LiveView

これは Phoenix.LiveView では、 mount/3 で行います。

# lib/tlwnd_ddm_web/live/nav_bar.ex
def mount(_assigns, _session, socket) do
  {:ok, assign(socket, isOpen: false)}
end

プロパティのトグル操作

Vue

以下で @click イベントハンドラにより、ボタンが押されるたびにトグル動作が行われるようになっています。

<!-- src/components/NavBar.vue -->
 <button @click="isOpen = !isOpen" type="button" class="block text-gray-500 hover:text-white focus:text-white focus:outline-none">
Phoenix.LiveView

LiveView では、phx-clickhandle_event/3 で受けて行います。

# lib/tlwnd_ddm_web/templates/nav_bar/index.html.leex
<button type="button" phx-click="toggle_menu" class="block text-gray-500 hover:text-white focus:text-white focus:outline-none">
# lib/tlwnd_ddm_web/live/nav_bar.ex
def handle_event("toggle_menu", _, socket) do
  {:noreply, assign(socket, isOpen: !socket.assigns.isOpen)}
end

isOpenを使用した表示制御

Vue

以下のように isOpen に対応したクラスが指定されています。

<nav :class="isOpen ? 'block' : 'hidden'" class="sm:block">
Phoenix.LiveView

同じような記述で実現できます。

<div class="<%= if @isOpen do "block" else "hidden" end %> sm:block">

isOpenの状態による表示の変更

Vue

isOpen の状態によって、表示させる要素を変えている記述があります。
以下では、isOpentrue のときに表示される要素と、false のときに表示される要素を並列に記述しています。

<!-- src/components/NavBar.vue -->
  <svg class="h-6 w-6 fill-current" viewBox="0 0 24 24">
    <path v-if="isOpen" fill-rule="evenodd" d="M18.278 16.864a1 1 0 0 1-1.414 1.414l-4.829-4.828-4.828 4.828a1 1 0 0 1-1.414-1.414l4.828-4.829-4.828-4.828a1 1 0 0 1 1.414-1.414l4.829 4.828 4.828-4.828a1 1 0 1 1 1.414 1.414l-4.828 4.829 4.828 4.828z"/>
    <path v-if="!isOpen" fill-rule="evenodd" d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>
  </svg>
Phoenix.LiveView

Phoenix では条件分岐で記述しています。
View 側にパターンマッチに対応した関数を設けることで条件分岐をせずに記述できますが、コードの見た目の互換性を考え今回はこのようにしました。

# lib/tlwnd_ddm_web/templates/nav_bar/index.html.leex
  <svg class="h-6 w-6 fill-current" viewBox="0 0 24 24">
     <%= if @isOpen do %>
       <path fill-rule="evenodd" d="M18.278 16.864a1 1 0 0 1-1.414 1.414l-4.829-4.828-4.828 4.828a1 1 0 0 1-1.414-1.414l4.828-4.829-4.828-4.828a1 1 0 0 1 1.414-1.414l4.829 4.828 4.828-4.828a1 1 0 1 1 1.414 1.414l-4.828 4.829 4.828 4.828z"/>
     <%= else %>
       <path fill-rule="evenodd" d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>
     <% end %>
  </svg>

AccountDropdownコンポーネントのレンダリング

Vue

NavBar コンポーネントから AccountDropdown コンポーネントをレンダリングしています。
コンポーネントには複数のクラスが指定されています。

<AccountDropdown class="hidden sm:block sm:ml-6"/>
Phoenix.LiveView

Phoenix 側では、live_render で直接クラスが指定できないので、div で囲むことによって実現しています。

<div class="hidden sm:block sm:ml-6">
  <%= live_render @socket, TlwndDdmWeb.AccountDropdown, id: "account" %>
</div>

AccountDropdown.vue

先程レンダリングしたAccountDropdownです。

エスケープキーでドロップダウンを閉じる

Vue

create() のタイミングで keydown 用のイベントハンドラを登録しています。
イベントハンドラではエスケープキーの場合に isOpenfalse に設定しています。

<script>
export default {
  :
  created() {
    const handleEscape = (e) => {
      if (e.key === 'Esc' || e.key === 'Escape') {
        this.isOpen = false
      }
    }
    document.addEventListener('keydown', handleEscape)
    this.$once('hook:beforeDestroy', () => {
      document.removeEventListener('keydown', handleEscape)
    })
  }
}
</script>
Phoenix.LiveView

phx-window-keydown というWindowレベルのキーバインディングを行い、

<div class="relative" phx-window-keydown="keydown">

イベントをハンドリングした後、パターンマッチによりエスケープキーが押された場合の関数で処理を行っています。

def handle_event("keydown", arg = %{"code" => "Escape"}, socket) do
  {:noreply, assign(socket, isOpen: false)}
end

def handle_event("keydown", _, socket) do
  {:noreply, socket}
end

変換作業はこれで終了です。

おわりに

Tailwind CSS は、思った以上に自分にとってとっつきやすいCSSフレームワークでした。
Phoenix.LiveView でも効果的に使えそうです。
Bootstrap 等のように、Javascript を使用しているような UI について Phoenix.LiveView でどのように実現するかは頭の使い所ですね。

今回作成したソースコードは以下に置きました。

TlwndDdm