Phoenix LiveView アプリケーションに Flowbite の UI コンポーネントを導入する

## はじめに

Phoenix LiveView は Elixir の Web フレームワークです

JavaScript をほぼ書くことなく、 Elixir でフロントエンドもバックエンドも実装できます

https://github.com/phoenixframework/phoenix_live_view

Flowbite は Tailwind CSS を使った UI コンポーネントのライブラリです

簡単に導入でき、様々な Web フレームワークと一緒に利用できます

https://flowbite.com/

Phoenix LiveView アプリケーションに Flowbite を導入する手順について、 Flowbite のドキュメントに掲載されています

https://flowbite.com/docs/getting-started/phoenix/

しかし、情報が古い（Phoenix 1.6 時点）ので、2024年12月現在（Phoenix 1.7 時点）での手順を残しておきます

実装したアプリケーションはこちら

https://github.com/RyoWakabayashi/phoenix_flowbite_sample

## 実行環境（前提条件）

Erlang と Elixir 、 Node.js をインストールしているものとします

各言語のバージョン

- Erlang 27.2
- Elixir 1.17.3
- Node.js 22.12.0

macOS であれば [mise](https://github.com/jdx/mise) や [asdf](https://asdf-vm.com/) を使ってインストールするのがおすすめです

mise の場合

```bash
mise use -g erlang@27.2
mise use -g elixir@1.17.3
mise use -g node@22.12.0
```

asdf の場合

```bash
asdf plugin add erlang
asdf install erlang 27.2
asdf global erlang 27.2

asdf plugin add elixir
asdf install elixir 1.17.3
asdf global elixir 1.17.3

asdf plugin add nodejs
asdf install nodejs 22.12.0
asdf global nodejs 22.12.0
```

elixir のパッケージマネージャー [Hex](https://hex.pm/) をインストールします

```bash
mix local.hex
```

Phoenix プロジェクトを生成するための [phx_new](https://hex.pm/packages/phx_new) をインストールします

```bash
mix archive.install hex phx_new
```

## Phoenix プロジェクトの作成

Phoenix プロジェクトを作成します

今回は UI だけ見たいので DB 操作をしないため `--no-ecto` のオプションを付けています

```bash
mix phx.new phoenix_flowbite_sample --no-ecto
```

`Fetch and install dependencies? [Yn]` と質問されるので、そのまま Enter を押下（デフォルトの yes で回答）します

phoenix_flowbite_sample ディレクトリーが作成されるので、中に移動します

```bash
cd phoenix_flowbite_sample
```

Phoenix アプリケーションを起動します

```bash
mix phx.server
```

http://localhost:4000 にアクセスすると、以下のような画面が表示されます

![スクリーンショット 2024-12-25 10.35.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5580d361-675f-003f-1ffe-55755f3de7e4.png)

一旦、 Ctrl + C でアプリケーションを停止します

## Flowbite の導入

Flowbite のドキュメントでは Tailwind CSS 導入の手順を書いていますが、 Phoenix 1.7 ではデフォルトで Tailwind CSS インストール済になっています

Tailwind CSS 導入の手順を飛ばして、 Flowbite を導入します

assets ディレクトリーに移動します

```bash
cd assets
```

以下の内容で `assets/package.json` ファイルを作成します

```json
{
  "name": "phoenix_flowbite_sample",
  "version": "1.0.0",
  "main": "js/app.jp",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "flowbite": "^2.5.2"
  }
}
```

Flowbite をインストールします

```bash
npm install
```

上のディレクトリーに戻っておきます

```bash
cd ..
```

`assets/tailwind.config.js` を以下のように編集します

```diff_json
...
module.exports = {
  content: [
    "./js/**/*.js",
+   "./node_modules/flowbite/**/*.js",
    "../lib/phoenix_flowbite_sample_web.ex",
    "../lib/phoenix_flowbite_sample_web/**/*.*ex"
  ],
  ...
  plugins: [
+   require('flowbite/plugin'),
    require("@tailwindcss/forms"),
  ...
}
```

`assets/js/app.js` を以下のように編集します

```diff_javascript
...
import topbar from "../vendor/topbar"
+import "flowbite/dist/flowbite.phoenix.js"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
...
```

## Flowbite コンポーネントの配置

`lib/phoenix_flowbite_sample_web/controllers/page_html/home.html.heex` を以下の内容に書き換えます

```heex
<.flash_group flash={@flash} />
<div class="px-20 py-10 sm:px-20 sm:py-28 lg:px-20 xl:px-60 xl:py-32">
  <div id="alerts">
    <div
      class="flex items-center p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-gray-800 dark:text-blue-400"
      role="alert"
    >
      <svg
        class="flex-shrink-0 inline w-4 h-4 me-3"
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
      </svg>
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Info alert!</span>
        Change a few things up and try submitting again.
      </div>
    </div>
    <div
      class="flex items-center p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400"
      role="alert"
    >
      <svg
        class="flex-shrink-0 inline w-4 h-4 me-3"
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
      </svg>
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Danger alert!</span>
        Change a few things up and try submitting again.
      </div>
    </div>
    <div
      class="flex items-center p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400"
      role="alert"
    >
      <svg
        class="flex-shrink-0 inline w-4 h-4 me-3"
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
      </svg>
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Success alert!</span>
        Change a few things up and try submitting again.
      </div>
    </div>
    <div
      class="flex items-center p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-gray-800 dark:text-yellow-300"
      role="alert"
    >
      <svg
        class="flex-shrink-0 inline w-4 h-4 me-3"
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
      </svg>
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Warning alert!</span>
        Change a few things up and try submitting again.
      </div>
    </div>
    <div
      class="flex items-center p-4 text-sm text-gray-800 rounded-lg bg-gray-50 dark:bg-gray-800 dark:text-gray-300"
      role="alert"
    >
      <svg
        class="flex-shrink-0 inline w-4 h-4 me-3"
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
      </svg>
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Dark alert!</span>
        Change a few things up and try submitting again.
      </div>
    </div>
  </div>
</div>
```

`<div id="alerts">...</div>` 内部については Flowbite のドキュメントからコピーしたものです（貼り付けて保存後、 `mix format` を実行して整形しています）

https://flowbite.com/docs/components/alerts/#alerts-with-icon

フロントエンドだけで動作が完結するものに関してはコピー&ペーストだけで実装終了です

この状態でアプリケーションを起動します

```bash
mix phx.server
```

http://localhost:4000 にアクセスすると、以下の画面が表示されます（ライトモードの表示）

![スクリーンショット 2024-12-25 11.23.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c7931ccd-82d3-30bf-baa7-da55887088eb.png)


その他、アコーディオンなど、様々なコンポーネントが Flowbite 公式ドキュメントに掲載されています

https://flowbite.com/docs/components/accordion/

## コンポーネント化

このままだと扱いにくいので、 Phoenix LiveView のコンポーネントにしてしまいましょう

以下の内容で `lib/phoenix_flowbite_sample_web/components/flowbite_components.ex` を作成します

```elixir
defmodule PhoenixFlowbiteSampleWeb.FlowbiteComponents do
  use Phoenix.Component
  use Gettext, backend: PhoenixFlowbiteSampleWeb.Gettext

  attr :id, :string, required: true
  attr :type, :atom, default: :info, values: [:info, :success, :warning, :danger, :dark]
  slot :inner_block, required: true

  def alert(assigns) do
    ~H"""
    <div
      id={@id}
      class={[
        "flex items-center p-4 mb-4 text-sm rounded-lg dark:bg-gray-800",
        case @type do
          :info -> "text-blue-800 bg-blue-50 dark:text-blue-400"
          :success -> "text-green-800 bg-green-50 dark:text-green-400"
          :warning -> "text-yellow-800 bg-yellow-50 dark:text-yellow-400"
          :danger -> "text-red-800 bg-red-50 dark:text-red-400"
          :dark -> "text-gray-800 bg-gray-50 dark:text-gray-400"
        end
      ]}
      role="alert"
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  def info_icon(assigns) do
    ~H"""
    <svg
      class="flex-shrink-0 inline w-4 h-4 me-3"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      fill="currentColor"
      viewBox="0 0 20 20"
    >
      <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" />
    </svg>
    """
  end
end
```

`lib/phoenix_flowbite_sample_web.ex` を以下のように編集します

```diff_elixir
defmodule PhoenixFlowbiteSampleWeb do
  ...
  defp html_helpers do
    quote do
      # Translation
      use Gettext, backend: PhoenixFlowbiteSampleWeb.Gettext

      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components
      import PhoenixFlowbiteSampleWeb.CoreComponents
+     import PhoenixFlowbiteSampleWeb.FlowbiteComponents

      # Shortcut for generating JS commands
      alias Phoenix.LiveView.JS

      # Routes generation with the ~p sigil
      unquote(verified_routes())
    end
  end
  ...
end
```

これにより、 `lib/phoenix_flowbite_sample_web/controllers/page_html/home.html.heex` は以下のように書き換えられます

```heex
<.flash_group flash={@flash} />
<div class="px-20 py-10 sm:px-20 sm:py-28 lg:px-20 xl:px-60 xl:py-32">
  <div id="alerts">
    <.alert id="info-alert" type={:info}>
      <.info_icon />
      <span class="sr-only">Info</span>
      <div>
        <span class="font-medium">Info alert!</span>
        Change a few things up and try submitting again.
      </div>
    </.alert>
    <.alert id="danger-alert" type={:danger}>
      <.info_icon />
      <span class="sr-only">Danger</span>
      <div>
        <span class="font-medium">Danger alert!</span>
        Change a few things up and try submitting again.
      </div>
    </.alert>
    <.alert id="success-alert" type={:success}>
      <.info_icon />
      <span class="sr-only">Success</span>
      <div>
        <span class="font-medium">Success alert!</span>
        Change a few things up and try submitting again.
      </div>
    </.alert>
    <.alert id="yellow-alert" type={:warning}>
      <.info_icon />
      <span class="sr-only">Warning</span>
      <div>
        <span class="font-medium">Warning alert!</span>
        Change a few things up and try submitting again.
      </div>
    </.alert>
    <.alert id="gray-alert" type={:dark}>
      <.info_icon />
      <span class="sr-only">Dark</span>
      <div>
        <span class="font-medium">Dark alert!</span>
        Change a few things up and try submitting again.
      </div>
    </.alert>
  </div>
</div>
```

かなりスッキリしましたね

コンポーネント化する場合、 CoreComponents で定義されているコンポーネントとの衝突（同じ名前の関数）に気をつけましょう

## Flowbite Datepicker の導入

Datepicker は JavaScript 実装の都合上、フックを利用する必要があります

`assets/js/app.js` を以下のように編集します

```javascript
...
import Datepicker from "flowbite-datepicker/Datepicker"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

Hooks = {}

Hooks.Datepicker = {
    mounted() {
        const datepickerEl = this.el;
        new Datepicker(datepickerEl, {
            // options
        });
    },
    updated() {
        this.mounted();
    }
}

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})
...
```

Datepicker をインポートし、 `Datepicker` のフックを持つ DOM 要素が追加されたときに Datepicker が動くようにしています

`lib/phoenix_flowbite_sample_web/controllers/page_html/home.html.heex` について、以下のように `phx-hook="Datepicker"` を持つ input 要素を追加してみます

```heex
<.flash_group flash={@flash} />
<div class="px-20 py-10 sm:px-20 sm:py-28 lg:px-20 xl:px-60 xl:py-32">
  <input
    phx-hook="Datepicker"
    id="myInput"
    type="text"
    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 mb-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
    placeholder="Select date"
  />
  ...
```

この状態でアプリケーションを起動すると、 DatePicker で日付選択ができます

![datepicker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2e3165f9-85c6-41b1-0662-4a7333662305.gif)

## まとめ

Flowbite を Phoenix LiveView アプリケーションに導入できました

Pines や SaladUI など、コンポーネントライブラリーは色々あるので、デザインが良いもの、便利なものなどを選びましょう