Railsの技: HotwireとRailsで使える新しいCSSトリック(翻訳)
Hotwireアプリケーションでは、CSSやHTMLの基礎をさらに深く学ぶ必要があります。皆さんも、おそらく私と同様にCSSを曲りなりにも使えるレベルまでは学んでいるかと思いますが、最初にいきなりCSSから手を付ける人はまずいないでしょう。しかし最近は状況が変わってきたので、私が最近見つけたRailsアプリを改善するパターンをいくつか共有したいと思います。
🔗 1. 空のステートとTurbo Streams
Railsでは、要素のコレクションをレンダリングし、コレクションが空の場合は空のステートをレンダリングするというパターンが非常によく使われます。
<div id="my_list" class="flex flex-col divide-y">
  <% if @list.size > 0 %>
    <%= render partial: "list_item", collection: @list %>
  <% else %>
    <p>
      Whoops! you have no items!
    </p>
  <% end %>
</div>
一般的なページをレンダリングするなら、これで問題ありませんが、Turbo Streamsでリストアイテムを追加・削除すると問題が発生します。
リストアイテムが2個あり、Turbo Streamsでこの2個を削除したとすると、コンテナは空になりますが、空のステートはレンダリングできません。また、リストが空の状態で動的にアイテムを追加する場合は、空のステートを削除したくなるでしょう。
アイテムを個別に挿入する代わりにリスト全体を再レンダリングする方法も一応考えられますが、こういうときはCSSのonly-child疑似セレクタが有用であることに気づきました。
参考: :only-child - CSS: カスケーディングスタイルシート | MDN
Tailwindがあまりに優秀なので↓、以下のサンプルはTailwindで説明しますが、通常のCSSでも考え方は同じです。
Tailwind is so fucking good
— matt swanson 😈 (@_swanson) July 22, 2022
考え方としては、常に空のステートをレンダリングし、アイテムがない場合はCSSで空のステートを表示するというものです。
<div id="my_list" class="flex flex-col divide-y">
  <p class="only:block hidden">Whoops! you have no items!</p>
  <%= render partial: "list_item", collection: @list %>
</div>
Tailwindのonly1モディファイアを使って、空のステートが唯一の子要素の場合は空のステートをblockで表示し、それ以外の場合は非表示にします。
これで、アイテムを再びストリームでmy_listに追加・削除できるようになり、「読み込み中」ステートをCSSで表示・非表示できるようになります。
メモ: マークアップによっては、last-child2やfirst-of-type3のような別のモディファイアを使ってみてもよいでしょう。お試しあれ。
🔗 2. data-*属性でTailwindのバリアントを使う
Hotwire、特にStimulusではHTMLのdata-*属性を多用します。data-*属性をうまく使えば、ビューの条件表示を削減できます。
たとえば、以下のようなコメントがあり、adminだけがコメントを削除できるとします。
<%= tag.div id: dom_id(@comment) do %>
  <p><%= @comment.body %></p>
  <% if Current.user.admin? %>
    <%= button_to "Delete", @comment, method: :delete %>
  <% end %>
<% end %>
そのページの<body>タグにdata-admin属性を書いて、adminに関連する場合にのみコンテンツを表示できるようにします。
<body <%= 'data-admin' if Current.user.admin? %>>
  ...
</body>
次に、このdata-admin属性に応じたスタイルを書きます。Tailwindでは、Tailwind.configファイルのpluginsセクションにカスタムバリアントを以下のように手軽に追加できます。
plugins: [
  function({addVariant}) {
    addVariant('admin', 'body[data-admin] &')
  }
],
上のコンフィグによって、以下のようにあらゆるTailwindクラスで admin:を書けるようになります。
<%= tag.div id: dom_id(@comment) do %>
  <p><%= @comment.body %></p>
  <div class="admin:block hidden">
    <%= button_to "Delete", @comment, method: :delete %>
  </div>
<% end %>
「待った、そんなことをしたら誰かがHTMLをいじってコメントを削除できるからめちゃくちゃ危険では?」はい、一応その可能性は無きにしもあらずですが、サーバーサイドでの認証はいずれにしろチェックする必要があります。トレードオフはあるものの、これを使って条件付き表示のロジックを一掃できる場合もよくあります。
このアイデアを授けてくれた友人のMarc Kohlbruggeに感謝します。
One fun Tailwind CSS trick I came up with is creating my own role-based variants.
For example to show certain elements only to admins, I make my body look like: <body data-admin> and add the classes "hidden admin:block" to any elements that should only be visible to admins pic.twitter.com/Qs23ypMGAV
— marckohlbrugge.eth (@marckohlbrugge) July 5, 2022
最近私たちのアプリでは、要素で必要なマージンをユーザーのロールに応じて変更するのにこの技を使いました。一部のロールには高さ固定のヘッダーがあり、この点を考慮する必要があったのです。最終的に、条件付き表示を山ほど書く代わりに、 viewer:top-0 editor:top-12を書くだけで済むようになりました。
🔗 3. ERBで動的なスタイルを使う
<style>タグは動的に生成できることをお忘れなく。
<style>
  [data-user~="<%= Current.user.id %>"] {
    background: yellow;
  }
</style>
<div>
  <p><%= @comment.body %></p>
  <%= tag.span data: { user: @comment.author.id } do %>
    <%= @comment.author.name %>
  <% end %>
</div>
data-user属性を対象とするCSSを少し書くだけで、自分が作成したコメントが黄色の背景で強調表示されるようになります。
実は、これはBasecampに古くから伝わる技で↓、フラグメントキャッシュとの相性が非常によいので広く使われています。少しずつ異なる大量のキャッシュエントリを必要とせずに、同じHTMLチャンクをキャッシュしておいてCSSでスタイルを変更できます。
参考: How Basecamp Next got to be so damn fast without using much client-side UI – Signal v. Noise
私は、CSS変数を利用したカスタムテーマ機能の構築にもこの考えを応用したことがあります。
<style>
  :root {
    --color-brand: <%= @account.brand_color %>;
    --color-brand-contrast: <%= ColorHelper.contrast(@account.brand_color) %>;
    --color-brand-tint: <%= ColorHelper.tint(@account.brand_color) %>;
  }
</style>
上のスタイルを用いて、Tailwindのカスタムカラーを定義できます。
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: 'rgb(var(--color-brand) / <alpha-value>)',
        'brand-contrast': 'rgb(var(--color-brand-contrast) / <alpha-value>)',
        'brand-tint': 'var(--color-brand-tint)'
      }
    }
  }
}
これで、アプリケーションでbg-brandや text-brand-contrastを使えるようになります。
🔗 4. クラス名を式展開するのはもう止めよう
Hotwireアプリでは、ビューやパーシャルやコンポーネントで今まで以上に多くのHTMLマークアップを書くことになります。文字列の式展開を大量に書かずにHTMLを生成できるRailsの新機能を活用しましょう。
以下のようなマークアップを書いているとします。
<li class="bg-gray-50 p-2 text-gray-700 <%= 'line-through' if @task.completed? %>">
  <%= @task.name %>
</li>
ちょっと待った!こんな書き方は読みづらいうえに、引用符の開きと閉じをもれなく合わせるのが面倒です。もっといい書き方があります。
<%= tag.li class: ["bg-gray-50 p-2 text-gray-700", "line-through": @task.completed?] do %>
  <%= @task.name %>
<% end %>
Rails 6.1でclass_namesヘルパーメソッドが追加されています(#37918)。tagビルダーは内部でこのヘルパーを使って、自動的にクラスの値を条件付きで設定してくれます。素晴らしいですね!
訳注
以下は原著者のツイートです。
🎨 Fan of the popular "classnames" JS/React library of applying conditional CSS classes?
There are new helpers to support this in #Rails 6.1 (courtesy of @joelhawksley + GitHub)https://t.co/5rjxD4aGcf pic.twitter.com/hjcyoAQ7oq
— matt swanson 😈 (@_swanson) December 21, 2020
このヘルパーは、ViewComponent4のようなライブラリを使って条件付きスタイルを大量に設定する場合や、ユーティリティクラスをグループ化して整理したい場合に特に威力を発揮します。
class MyWidget < ViewComponent::Base
  ...
  def container_classes
    [
      "flex items-center justify-center space-x-2 rounded-full",
      "disabled:pointer-events-none disabled:select-none",
      "font-medium tracking-wide",
      {"text-white bg-black hover:bg-neutral-900": variant == :primary},
      {"text-neutral-600 border hover:bg-neutral-50 hover:text-neutral-900": variant == :secondary},
      {"text-neutral-600 hover:text-neutral-900 hover:bg-neutral-50": variant == :tertiary},
      {"w-full": full_width?}
    ]
  end
end
まとめ
CSSは、Rails開発者が面倒な問題を解決するときに最後の手段としてよく使われるツールです。私たちはついつい、ビューに条件式を追加したり式展開を使ったりして切り抜けようとする傾向があります。しかし、CSSを使うことでRailsアプリのコードの読みやすさと耐久性を向上させる手法はいくつもあります。
Hotwireのサーバーサイドレンダリングは古い技術のように感じられるかもしれませんが、現代に生きる私たちは、もはや1998年頃のようなCSSを使う必要などないことをどうかお忘れなく。
本記事が役に立つと思った方は、ぜひフォームでニュースレターの登録をお願いします。余分な文章を削ぎ落とした読みやすく価値ある情報だけをニュースレターで配信いたします。
 
       
                      
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
原文タイトルのgalaxy-brainは「人智を超えた」というようなニュアンスの流行語です。
参考: galaxy-brain - Wiktionary