- 実践ViewComponent(1): 現代的なRailsフロントエンド構築の心得(翻訳)
- 実践ViewComponent(2): コンポーネントを徹底的に強化する(翻訳)
- 実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)-- 本記事
実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳)
はじめに
Ruby on Railsのフルスタック開発がHTMLファーストという方法によって軌道に戻りました!ここからさらに前進するには、ビュー層の管理方法を改善する優れたツールが必要です。GitHubが開発したViewComponentライブラリは、HTMLを健全にするための選択肢として現在もRails開発者の間で筆頭の座についています。私たちEvila Martiansはしばらく前からViewComponentライブラリを使い続けており、本シリーズ開始以来つちかってきたさまざまなヒントやテクニックを新たに紹介できるようになりました。本記事では、TailwindCSSスタイルをViewComponentに統合する方法と、HTML属性をViewComponentの複数のコンポーネントに伝搬させる方法について解説します。それでは皆さま、シートベルトをお締めください!
私たちは数年前から、HTMLテンプレートとパーシャルを整理するためにViewComponentの採用を始めました。当初は主にコードを管理下に置くためのソフトウェアデザインパターンとしてViewComponentを使ってきましたが、エコシステムと私たちの経験が進化を遂げるに連れて、ViewComponentはアプリケーションのデザインシステムを構築するうえで重要な位置を占めるようになりました。
デザインシステムとは、再利用可能な要素やUIを作成するためのガイドラインを集約したものです。コード内では、デザインシステムはUIキット(ビュー層内でデザインシステムのUI要素の実装を担当する部分)を介して表現されます。「再利用可能な要素」は、そのまま自然にコンポーネント化に結びつきます。
しかしUIキットをメンテナンスするには、そのためのストーリーブックが不可欠です。シリーズの前回記事でも説明したように、ViewComponentでLookbookというストーリーブックを併用することで、デザインシステム主導のUI開発に必要なものをすべて得られます。
RubyやRails向けのUIコンポーネントライブラリには、他にもPhlexUI、RailsUI、ZestUIもあります(PhlexUIで用いられているPhlexもRuby向けのビューコンポーネント用ライブラリです)。
生産性を重視するRailsでコードを書くときに最も生産性を高める方法は、コードをまったく書かずに済むようにすることです。RubyアプリやRailsアプリを対象とする既存のUIキットはまだ希少で、ほとんどがアルファ段階なので、アプリケーションではUIライブラリを自作する必要が生じる可能性もあります。しかしどうかご心配なく!私たちは既にこの方面での経験をいくつか積み重ねており、ViewComponentを用いたUIキット開発の手間を軽減させる貴重なノウハウを公開できる段階に到達しています。本日のメニューは以下になります。
🔗 スタイルバリアント
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
機能を利用しています。
このコンポーネントは以下のようにレンダリングできます。
<%= render UIKit::Button::Component.new do %>
Click Me
<% end %>
これにより以下のUIを得られます。
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>
ボタンのバリアント
ここでのコツは、クラスの動的な部分を定数に切り出したことです。しかしこれはさまざまなバリアントの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-50
(STYLES[:outline]
で導入される)とbg-slate-300
(#disabled_classes
で導入される)がかち合ってしまい、その結果前者が勝ってしまうことです。
コンポーネントのスタイルをすっきりさせて、メンテナンス性と正確さを取り戻すにはどうすればよいでしょうか?そこで、Style Variantsを紹介いたします。
Style Variantsは私たちのview_component-contribパッケージにプラグインとして収録されており、Tailwind VariantsとCVAにインスパイアされています。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と統合する方法が使えます。
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
、 disabled
、autocomplete
など)をすべてサポートする必要があります。以下のような汎用の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で利用できますので、どうぞお試しあれ!
🔗 最後に一言
本シリーズの最初の記事を公開してから本記事を公開するまでの1年半の間に、Railsフルスタックアプリケーションを構築する方法は変わりました。TailwindCSSが戦いを制し(以前の私たちはPostCSS Modulesを実験していました)、Webpackerが廃止され、さまざまなUIコンポーネントライブラリがついにRailsの世界にも登場しました。
それでもViewComponentは引き続き私たちのUI技術の中核を担っており、そのおかげで絶え間なく変化を繰り返すソフトウェア開発トレンドにも楽に対応できています。私たちがViewComponentに投資する理由は、まさにそこにあるのです。
Evil Martiansは、成長段階のスタートアップ企業をユニコーン企業に飛躍させるためにサポートいたします。開発ツールの構築やオープンソース製品の開発も行っています。ワープの準備が整ったお客様、ぜひフォームまでご相談をお寄せください!
概要
元サイトの許諾を得て翻訳・公開いたします。