Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)

はじめに

Ruby on Railsのフルスタック開発がHTMLファーストという方法によって軌道に戻りました!ここからさらに前進するには、ビュー層の管理方法を改善する優れたツールが必要です。GitHubが開発したViewComponentライブラリは、HTMLを健全にするための選択肢として現在もRails開発者の間で筆頭の座についています。私たちEvila Martiansはしばらく前からViewComponentライブラリを使い続けており、本シリーズ開始以来つちかってきたさまざまなヒントやテクニックを新たに紹介できるようになりました。本記事では、TailwindCSSスタイルをViewComponentに統合する方法と、HTML属性をViewComponentの複数のコンポーネントに伝搬させる方法について解説します。それでは皆さま、シートベルトをお締めください!

viewcomponent/view_component - GitHub

私たちは数年前から、HTMLテンプレートとパーシャルを整理するためにViewComponentの採用を始めました。当初は主にコードを管理下に置くためのソフトウェアデザインパターンとしてViewComponentを使ってきましたが、エコシステムと私たちの経験が進化を遂げるに連れて、ViewComponentはアプリケーションのデザインシステムを構築するうえで重要な位置を占めるようになりました。

デザインシステムとは、再利用可能な要素やUIを作成するためのガイドラインを集約したものです。コード内では、デザインシステムはUIキット(ビュー層内でデザインシステムのUI要素の実装を担当する部分)を介して表現されます。「再利用可能な要素」は、そのまま自然にコンポーネント化に結びつきます。

しかしUIキットをメンテナンスするには、そのためのストーリーブックが不可欠です。シリーズの前回記事でも説明したように、ViewComponentでLookbookというストーリーブックを併用することで、デザインシステム主導のUI開発に必要なものをすべて得られます。

RubyやRails向けのUIコンポーネントライブラリには、他にもPhlexUIRailsUIZestUIもあります(PhlexUIで用いられているPhlexもRuby向けのビューコンポーネント用ライブラリです)。

生産性を重視するRailsでコードを書くときに最も生産性を高める方法は、コードをまったく書かずに済むようにすることです。RubyアプリやRailsアプリを対象とする既存のUIキットはまだ希少で、ほとんどがアルファ段階なので、アプリケーションではUIライブラリを自作する必要が生じる可能性もあります。しかしどうかご心配なく!私たちは既にこの方面での経験をいくつか積み重ねており、ViewComponentを用いたUIキット開発の手間を軽減させる貴重なノウハウを公開できる段階に到達しています。本日のメニューは以下になります。

  1. スタイルバリアント
  2. HTML属性を複数コンポーネントに伝搬させる
  3. コンポーネントのブラウザテスト

🔗 スタイルバリアント

TailwindCSSはUI開発世界において覇を唱えました。すべてのスタイルを定義済みのHTMLクラスで定義できるのであれば、わざわざCSSルールやネストや命名(BEMやSMACSSなど)で頑張る必要があるでしょうか?TailwindCSSでは、HTMLを信頼できる唯一の情報源として定めており、最新のフルスタックRailsの"No Build"主義と特に相性が良くなります。

TailwindCSSを利用することで、CSSファイルに一切触らずに一貫したUIを開発できるようになります。デザインシステムの根幹部分(フォント、グリッドなど)やデザイントークンをtailwind.config.jsファイル内に定義しておけば、アトミック(かつ動的)なCSSクラスの魔法を楽しめます。ただしそれと引き換えに、HTML内で数十個のCSSクラスが必要になります。Buttonコンポーネントの例を考えてみましょう。

class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm ring-blue-700
      hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700
      dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700">
      <%= content %>
    </button>
  ERB
end

上のコード例では、ViewComponent V3で追加されたインラインテンプレート機能と、私たちのview_component-contribライブラリ(詳しくはシリーズ第2回を参照)で生成したApplicationComponent機能を利用しています。

palkan/view_component-contrib - GitHub

このコンポーネントは以下のようにレンダリングできます。

<%= render UIKit::Button::Component.new do %>
  Click Me
<% end %>

これにより以下のUIを得られます。

ベースButtonコンポーネント

Buttonコンポーネント

しかしこれは始まりに過ぎません。ボタンコンポーネントの形式を1種類提供すれば済むことはないので、UIキットには必ずボタンのバリアントも複数用意するものです。
そこで、このボタンのアウトライン(白抜き)バージョンを追加することを検討してみましょう。そのためには、指定したバリアントに応じて、いくつかのTailwindCSSクラスを条件付きで含める必要があります。

class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }

  STYLES = {
    default: "text-white bg-blue-600 ring-blue-700\
      hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2\
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700\
      dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700",
    outline: "bg-slate-50 hover:bg-slate-100 focus:outline-none\
      focus:ring-2 focus:ring-slate-600 focus:ring-offset-2\
      focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-slate-700\
      dark:ring-slate-950 dark:hover:bg-slate-800 dark:focus:ring-offset-slate-700"
  }.freeze

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>">
      <%= content %>
    </button>
  ERB
end

これで、ボタンをレンダリングするときにvariant: :outlineでバリアントを指定できるようになります。

<div class="flex flex-row space-x-4">
  <%= render UIKit::Button::Component.new do %>
    Click Me
  <% end %>
  <%= render UIKit::Button::Component.new(variant: :outline) do %>
    Click Me
  <% end %>
</div>

ボタンのバリアント2種類: デフォルトとアウトライン

ボタンのバリアント

ここでのコツは、クラスの動的な部分を定数に切り出したことです。しかしこれはさまざまなバリアントの1つの側面に過ぎず、実際にはさまざまなバリアントを組み合わせて使うのが普通です。

たとえば、サイズをsmallやfullなどのさまざまなバリエーションで使う可能性があります。バリアント同士が独立していない場合は、追加するクラスやその組み合わせをさらに増やすはめになります。たとえば、ボタンコンポーネントをdisabledで無効にするには、以下のようなコードを書く必要があるでしょう。

class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }
  option :disabled, default: proc { false }

  STYLES = {
    # ...
  }.freeze

  erb_template <<~ERB
    <button type="<%= type %>"
      class="items-center justify-center rounded-md border border-slate-300
      px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>
      <%= disabled_classes if disabled %>"
      <%= "disabled" if disabled %>>
      <%= content %>
    </button>
  ERB

  def disabled_classes
    if variant == :outline
      "opacity-75 bg-slate-300 pointer-events-none"
    else
      "opacity-50 pointer-events-none"
    end
  end
end

これではコンポーネントのコードがますます複雑になるばかりで、しかもこれは期待通りに動きません。問題は、アウトライン版のボタンを無効にしたときに、bg-slate-50STYLES[:outline]で導入される)とbg-slate-300#disabled_classesで導入される)がかち合ってしまい、その結果前者が勝ってしまうことです。

コンポーネントのスタイルをすっきりさせて、メンテナンス性と正確さを取り戻すにはどうすればよいでしょうか?そこで、Style Variantsを紹介いたします。

Style Variantsは私たちのview_component-contribパッケージにプラグインとして収録されており、Tailwind VariantsCVAにインスパイアされています。Style Variantsを使うと、以下のようにスタイルのルールを宣言的に定義できます。

class UIKit::Button::Component < ApplicationComponent
  option :type, default: proc { "button" }
  option :variant, default: proc { :default }
  option :disabled, default: proc { false }

  style do
    base {
      %w[
        items-center justify-center px-4 py-2
        text-sm font-medium
        border border-slate-300 shadow-sm rounded-md
        focus:outline-none focus:ring-offset-2
      ]
    }
    variants {
      variant {
        primary {
          %w[
            text-white bg-blue-600 ring-blue-700
            hover:bg-blue-700
            focus:ring-offset-blue-50
            dark:border-slate-950 dark:bg-blue-700 dark:text-blue-50 dark:ring-blue-950
            dark:hover:bg-blue-800
            dark:focus:ring-offset-blue-700
          ]
        }
        outline {
          %w[
            bg-slate-50
            hover:bg-slate-100
            focus:ring-slate-600 focus:ring-offset-blue-50
            dark:border-slate-950 dark:bg-slate-700 dark:ring-slate-950
            dark:hover:bg-slate-800
            dark:focus:ring-offset-slate-700
          ]
        }
      }
      disabled {
        yes { %w[opacity-50 pointer-events-none] }
      }
    }
    defaults { {variant: :primary, disabled: false} }
    # "compound"ディレクティブを用いると、
    # 指定の組み合わせが使われるときに
    # 追加するクラスを後追いで宣言可能になる
    compound(variant: :outline, disabled: true) { %w[opacity-75 bg-slate-300] }
  end

  erb_template <<~ERB
    <button type="<%= type %>" class="<%= style(variant:, disabled:) %>"<%= " disabled" if disabled %>>
      <%= content %>
    </button>
  ERB
end

スタイル設定のロジックは、すべてstyle do ... endブロック内に記述します。HTMLテンプレート内では#style(**)ヘルパーでバリアントを指定するだけで済みます。これで、コンポーネントコードのあちこちにCSSクラスが散らばらずに済みます。

スタイルバリアントの定義内でカスタム規則を強制することも可能です。たとえば、 CSSクラスが修飾子ごとにグループ化されていることに気づいたとしましょう(focus:*クラスとdark:*クラスを別の行に分けているなど)。RuboCop用のカスタムcopを書けば、この規則を強制する(さらにCSSクラスを自動配置する)ことも可能です。つまり、テキストの切れっ端をRubyコードに変えることで、新たなDXの可能性も広がります。

compoundディレクティブは、従来の#disabled_classesメソッドを置き換えるものです。クラス衝突1の問題はどのように解決されるのでしょうか?Style Variantsプラグインは、CSSクラスの本質について一切の仮定を措きません(つまりTailwindCSS固有ではないということです)。これをもう少し賢くしてCSS衝突を解決する方法を指示するために、tailwind_merge gemと統合する方法が使えます。

gjtorikian/tailwind_merge - GitHub

class ApplicationComponent < ViewComponentContrib::Base
  include ViewComponentContrib::StyleVariants

  style_config.postprocess_with do |classes|
    TailwindMerge::Merger.new.merge(classes.join(" "))
  end
end

Style Variantsプラグインは、TailwindCSSベースのUIコンポーネント操作のDXを大幅に強化します。HTMLが冗長なスタイル定義で汚染されることもなくなり、CSSクラスも整頓されて静的に分析可能になります。この手法によって、HTMLのclass属性をシンプルに定義できるようになりました。

では、それ以外の属性についてはどうでしょうか?HTML属性の伝搬について見ていきましょう。

🔗 HTML属性を複数コンポーネントに伝搬させる

ボタンやフォームのinputのような基本的な(アトミックな)UI要素については、HTMLで考えられる機能的属性(required1、 disabledautocompleteなど)をすべてサポートする必要があります。以下のような汎用のInputコンポーネントを考えてみましょう。

class UIKit::Input::Component < ApplicationComponent
  option :name

  option :id, default: proc { nil }
  option :type, default: proc { "text" }
  option :value, default: proc { nil }
  option :autocomplete, default: proc { "off" }
  option :placeholder, default: proc { nil }
  option :required, default: proc { false }
  option :disabled, default: proc { false }

  erb_template <<~ERB
    <span class="relative">
      <input type="<%= type %>"
        <% if id %> id="<%= id %>"<% end %>
        <% if value %> value="<%= value %>"<% end %>
        <% if name %> name="<%= name %>"<% end %>
        autocomplete="<%= autocomplete %>"
        <% if placeholder %> placeholder="<%= placeholder %>"<% end %>
        <% if required %> required<% end %>
        <% if disabled %> disabled<% end %>
      >
    </span>
  ERB
end

このコンポーネントには、そうした機能的属性が(定義済みの場合にのみ)注入されるテンプレートが大量に含まれています。Railsならcontent_tagが(さらにtext_field_tagも) 使えるのに、わざわざ生の文字列を使う理由には「ヘルパーと純粋なHTMLを混ぜたくない」「パフォーマンスのため」などさまざまな理由がありえます。いずれにしろ、ここで重要なのは、そうしたことが現場では起きる可能性があるということです。私は火のない所に煙を立てたわけではありません。

また、公開したいすべてのHTML属性を(dry-initializer.optionで)宣言することも一応可能ですが、これは最適とはほど遠いことが判明しました。理由の筆頭は、そうした属性の個数が多くなる可能性があり、それらのほとんどをHTMLにそのまま注入しなければならなくなることです。理由2は、そうした属性が外部由来であることが多く(Stimulusのdata-*属性や、ブラウザテストで使うtest_idなど)、せっかく分離したコンポーネントにその責務を負わせたくないことです。

明確さを高める第一歩として、複数の属性をまとめて扱うことを思いつきました。

class UIKit::Input::Component < ApplicationComponent
  option :name

  option :html_attrs, default: proc { {} }
  option :input_attrs, default: proc { {} }, type: -> { {autocomplete: "off", required: false}.merge(_1) }

  erb_template <<~ERB
    <span class="relative" <%= tag.attributes(**html_attrs) %>>
      <input <%= tag.attributes(**input_attrs) %>>
    </span>
  ERB

  def before_render
    input_attrs.merge({name:})
  end
end

可能なオプションをすべてリストアップする代わりに、html_attrs(コンテナ要素用)とinput_attrsの2つだけを追加しました。ハッシュをHTML属性の文字列に変換するために、Rails組み込みのtag.attributes(Rails 7から利用可能)を使いました。コンポーネントを利用するための新しいインターフェイスは以下のようになります。

<%= render UIKit::Input::Component.new(
  name: "name",
  input_attrs: {placeholder: "Enter your name", autocomplete: "on", autofocus: true}) %>

inputフィールドが重要であること(かつ必須であること)をコードで強調する目的で、inputフィールドの名前を引き続き別のオプションで渡すようになっている点にご注目ください。

この宣言DSLは、そのままでは理想的とは言えません(特にtype: ...の部分)。そこで、以下のようにAPIにシンタックスシュガーを加えることも可能です。

class UIKit::Input::Component < ApplicationComponent
  option :name

  html_option :html_attrs
  html_option :input_attrs, default: {autocomplete: "off", required: false}

  erb_template <<~ERB
    <span class="relative" <%= dots(html_attrs) %>>
      <input <%= dots(input_attrs) %>>
    </span>
  ERB
end

なお、#dotsは、JavaScriptでオブジェクトを参照するspread演算子...のエイリアスです。

🔗 コンポーネントのブラウザテスト

毎年恒例となっている「火星流ViewComponent」レポートの締めくくりとして、ささやかな拡張をもう1つ紹介したいと思います。これは、ViewComponentで開発するときのエクスペリエンスを改善するためのものです。

本シリーズのパート1では、コンポーネントに乗り換える主なメリットの1つとして「テストのしやすさ」を挙げました。ただし、単体テストだけではなく、Railsのシステムテストでコンポーネントを対話的にテストすることも可能です。

# spec/system/components/my_component_spec.rb

it "何か動的なことをする" do
  visit("/rails/view_components/my_component/default")
  click_on("JavaScript-infused button")

  expect(page).to have_content("動的な何か")
end

このようなテストはプレビュー機能に依存しているため、せっかくのストーリーブックが台無しになり、テスト環境と開発環境が癒着してしまいます。これに対処するため、テスト用に以下のインラインテンプレートを構築しました。

it "does some dynamic stuff" do
  visit_template <<~ERB
    <form id="myForm" onsubmit="event.preventDefault(); this.innerHTML = '';">
      <h2>Self-destructing form</h2>
      <%= render Button::Component.new(type: :submit, kind: :info) do %>
        Destroy me!
      <% end %>
    </form>
  ERB

  expect(page).to have_text "Self-destructing form"

  click_on("Destroy me!")

  expect(page).to have_no_text "Self-destructing form"
end

これで、テスト対象のHTMLをテスト自身の内部で直接定義可能になります。この機能はrails-intest-views gemで利用できますので、どうぞお試しあれ!

palkan/rails-intest-views - GitHub

🔗 最後に一言

本シリーズの最初の記事を公開してから本記事を公開するまでの1年半の間に、Railsフルスタックアプリケーションを構築する方法は変わりました。TailwindCSSが戦いを制し(以前の私たちはPostCSS Modulesを実験していました)、Webpackerが廃止され、さまざまなUIコンポーネントライブラリがついにRailsの世界にも登場しました。

それでもViewComponentは引き続き私たちのUI技術の中核を担っており、そのおかげで絶え間なく変化を繰り返すソフトウェア開発トレンドにも楽に対応できています。私たちがViewComponentに投資する理由は、まさにそこにあるのです。


Evil Martiansは、成長段階のスタートアップ企業をユニコーン企業に飛躍させるためにサポートいたします。開発ツールの構築やオープンソース製品の開発も行っています。ワープの準備が整ったお客様、ぜひフォームまでご相談をお寄せください!

関連記事

実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳)

実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)

Tailwind CSSをカオスにしないための5つのベストプラクティス(翻訳)


  1. 訳注: class conflictsは「階級闘争」という意味合いもあります。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。