f.datalistでRailsフォームにネイティブなオートコンプリートを作る(JavaScript不要)
Note: f.datalist はRails 8.2で登場します。main にはマージ済みですが、まだリリースされていません。プルリクエストを見る、Gemfileをmainブランチに向けて試す、またはデモアプリをクローンして bin/rails server を実行する、のいずれも可能です。待ちたくない場合、土台となる datalist_tag はRails 8.0から使えます。詳しくは後ほど。あわせて、もう一つの8.2の追加機能であるhas_json による型安全なJSON属性もどうぞ。
「このテキストボックスに候補を出したい」と「JavaScriptの依存、コントローラのアクション、Stimulusコントローラを追加してしまった」の間には、もどかしい隔たりがあります。ブラウザには何年も前からネイティブな答え、<datalist> 要素があり、Rails 8.2はそれを使うための FormBuilder ヘルパーを追加します。
はっきり言っておくと、これは新しい機能ではありません。datalistは datalist_tag(Rails 8.0)でも、その前なら素のHTMLでもすでに描画できました。f.datalist が加えるのはidの結線で、入力欄とそのリストがずれないようにするものです。小さなことですが、これは私が実際に本番へ出してしまったバグであり、修正は3行ではなく2行で済みます。
国名フィールドが入力に応じて候補を出すサインアップフォームを作ります。JSライブラリなし、fetch なし、クライアント側の状態なしです。
作るもの
ネイティブな候補リストに支えられた country 入力を持つ Profile フォームです。ブラウザがタイプアヘッドを担当し、form_with ブロックから出ることは一度もありません。
Railsの機能: f.datalist
f.datalist は既存の datalist_tag をラップし、入力欄とリストがつながるように要素のidを導出します:
# actionview/lib/action_view/helpers/form_options_helper.rb
def datalist(method, choices = nil, html_options = {})
@template.datalist_tag(field_id(method, "datalist"), choices, html_options)
end
肝心なのは field_id(method, "datalist") です。<datalist> は、入力欄の list 属性がdatalistの id と一致したときにのみ機能します。ヘルパーを使わない同じフォームは次のとおりです:
<%# idは、今や2か所にまったく同じ文字列でタイプするリテラルです %>
<%= f.text_field :country, list: "profile_country_datalist" %>
<%= datalist_tag "profile_country_datalist", Profile::COUNTRIES %>
これでも動きますが、idは2か所にハードコードされた文字列です。フィールドやモデルの名前を変えるとずれてしまい、オートコンプリートはエラーも出さずに静かに壊れます。field_id は両側でフィールド名から同じidを組み立てるので、常に一致します。
datalist_tag はRails 8.0からリリース版に入っているので、手動版はedge版のRailsなしで今日から動きます。f.datalist はそのidの手作業管理を取り除くだけです。
作っていく
まずは素朴なモデルから:
class Profile < ApplicationRecord
validates :country, presence: true
end
次にフォームです。重要なのは text_field と datalist の2行です:
<%= form_with model: @profile do |f| %>
<div>
<%= f.label :country %>
<%= f.text_field :country, list: f.field_id(:country, :datalist) %>
<%= f.datalist :country, ["Argentina", "Brazil", "Canada", "Chile", "Japan"] %>
</div>
<%= f.submit %>
<% end %>
入力欄の list: と f.datalist が生成するidはどちらも field_id(:country, :datalist) なので、ブラウザは両者を組にします。
これは次のようにレンダリングされます:
<input list="profile_country_datalist" type="text"
name="profile[country]" id="profile_country" />
<datalist id="profile_country_datalist">
<option value="Argentina">Argentina</option>
<option value="Brazil">Brazil</option>
<option value="Canada">Canada</option>
<option value="Chile">Chile</option>
<option value="Japan">Japan</option>
</datalist>
動かしてみる
国名フィールドに入力すると、ブラウザはフィルタされたドロップダウンを表示します。「C」でCanadaとChileに絞られ、「Ca」でCanadaに絞られます。一つ選べば入力欄が埋まりますし、datalistは値を制限するのではなく提案するだけなので、リストを無視して何でも打ち込むこともできます。
サーバーへは何も飛んでおらず、JavaScriptも実行されていません。フィルタリングはブラウザが自前で行いました。
スクリーンショットがないのは、ドロップダウンをページではなくOSが描画するため、キャプチャに写らないからです。本物を見るにはデモアプリを起動してください。
選択肢をデータベースから埋める
ハードコードした配列はデモには十分です。本来の使いどころは、自分のデータから候補を供給することです。choices 引数は options_for_select と同じ形式を受け取るので、クエリ結果をそのまま差し込めます:
<%= f.text_field :tag, list: f.field_id(:tag, :datalist) %>
<%= f.datalist :tag, Tag.where.not(name: [nil, ""]).distinct.order(:name).pluck(:name) %>
distinct と空値の除外で、重複や空文字列をリストから締め出せます。本番のコードでは、このクエリをスコープやモデルのメソッドに移しましょう。そしてフィールドはただの text_field なので、既存レコードの編集に追加の手間は要りません。保存済みの値が初期表示され、リストも引き続き提示されます。
[label, value] の形も、selectと同じように使えます:
<%= f.datalist :country_code,
[["Argentina", "AR"], ["Brazil", "BR"], ["Canada", "CA"]] %>
ただし <datalist> は <select> ではありません。これは <option value="AR">Argentina</option> をレンダリングし、datalistは入力された文字を value(“AR”)と照合し、value を挿入し、value を候補として表示します。テキスト(“Argentina”)はせいぜい補助的なヒントで、無視するブラウザもあります。selectで得られる「名前を表示し、コードを送信する」挙動はここには引き継がれず、「Arg」と打っても「Argentina」は見つかりません。ユーザーに打ってほしいものを value に入れましょう。つまり通常は、素の配列形式が求めているものです。
さらに進める
探究する価値のある方向をいくつか:
- 1つのフォームに複数のリスト。 各idはそれぞれのメソッド名から導出されるので、
f.datalist :countryとf.datalist :cityは決して衝突しません。好きなだけ追加できます。 - オプションごとのHTML。 この形式は末尾のオプションハッシュを受け取るので、
["Chile", "CL", { disabled: true }]は無効化されたオプションをレンダリングします。 - 共有リスト。 複数の入力欄が同じ集合を共有する場合は、素の
datalist_tag('shared_id', choices)に切り替え、各入力欄のlist:を"shared_id"に向けます。
注意すべきこと
datalistは候補を出すだけで、リスト外のものを打ち込むのを止めはしません。フィールドに残った値が、そのままコントローラに届きます。なのでサーバー側での検証は続けましょう。validates :country, presence: true に加え、既知の集合だけを受け付けるなら inclusion チェックも入れます。datalistは入力の快適さのためのもので、正しさはあくまでモデルが受け持ちます。
注意点が一つ。入力欄に autocomplete="off" を付けないでください。ブラウザ自身のオートフィルを抑えるためによくやる習慣ですが、SafariなどのWebKit系ブラウザはこれを「候補なし」と解釈してdatalistを隠してしまいます。Chromeでは出るのにSafariで出ないなら、ほぼこれが原因です。autocomplete は入力欄に付けないでおくか、実在するトークンを設定すれば、リストは戻ってきます。
もう一つの注意点。すべてのオプションはHTMLに乗って送られ、サーバー側のフィルタリングはありません。数百件なら問題ありません。「国内の全都市」や「4万人の全ユーザー」では、肥大したペイロードと、キーを打つたびにもたつくリストになります。f.datalist は、丸ごと送っても構わない範囲の限られた集合に使いましょう。それを超えるなら、エンドポイントに支えられた本物のオートコンプリートが欲しくなります。
ドロップダウンにも制約があります。スタイルを当てられず、スクリーンリーダーの対応はブラウザによってまちまちで、モバイルの挙動も端末ごとに異なります。既知の候補集合ならどれも大して問題になりませんが、スタイル付け、完全なアクセシビリティ、リモートでのフィルタリングが必要なら、そこがJavaScriptのコンボボックスの出番です。
まとめ
Railsフォームのネイティブなオートコンプリートは、いまや2行です。list: 属性を持つ text_field と、対応するidを導出する f.datalist です。「既知の集合から候補を出す」というよくあるケースなら、これで仕事は全部終わりです。実装は PR #57318 をご覧ください。