diff --git a/Gemfile.lock b/Gemfile.lock
index 92e3849..91ae8a7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -92,6 +92,7 @@ GEM
drb (2.2.1)
erubi (1.13.1)
ffi (1.17.1-arm64-darwin)
+ ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
formatador (1.1.0)
globalid (1.2.1)
@@ -146,6 +147,8 @@ GEM
nio4r (2.7.4)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
+ nokogiri (1.18.9-x86_64-darwin)
+ racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
notiffany (0.1.3)
@@ -233,6 +236,7 @@ GEM
securerandom (0.3.1)
shellany (0.0.1)
sqlite3 (2.7.3-arm64-darwin)
+ sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
stringio (3.1.1)
thor (1.3.2)
@@ -252,6 +256,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
+ x86_64-darwin-21
x86_64-linux
DEPENDENCIES
diff --git a/README.md b/README.md
index 1e92542..c1121c0 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
* **Works beautifully with ERB.** Start using Superform in your existing Rails app without changing a single ERB template. All the power, zero migration pain.
-* **Concise field helpers.** `field(:publish_at).date`, `field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation.
+* **Concise field helpers.** `Field(:publish_at).date`, `Field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation.
* **RESTful controller helpers** Superform's `save` and `save!` methods work exactly like ActiveRecord, making controller code predictable and Rails-like.
@@ -204,8 +204,8 @@ That looks like a LOT of code, and it is, but look at how easy it is to create f
# ./app/views/users/form.rb
class Users::Form < Components::Form
def view_template(&)
- labeled field(:name).input
- labeled field(:email).input(type: :email)
+ labeled Field(:name).input
+ labeled Field(:email).input(type: :email)
submit "Sign up"
end
@@ -252,7 +252,7 @@ class AccountForm < Superform::Rails::Form
# Renders input with the name `account[members][0][permissions][]`,
# `account[members][1][permissions][]`, ...
render permission.label do
- plain permisson.value.humanize
+ plain permission.value.humanize
render permission.checkbox
end
end
@@ -278,7 +278,7 @@ By default Superform namespaces a form based on the ActiveModel model name param
```ruby
class UserForm < Superform::Rails::Form
def view_template
- render field(:email).input
+ render Field(:email).input
end
end
@@ -294,7 +294,7 @@ To customize the form namespace, like an ActiveRecord model nested within a modu
```ruby
class UserForm < Superform::Rails::Form
def view_template
- render field(:email).input
+ render Field(:email).input
end
def key
@@ -333,7 +333,10 @@ class SignupForm < Components::Form
end
end
- # Let's get crazy with Selects. They can accept values as simple as 2 element arrays.
+ # Selects accept options as positional arguments. Each option can be:
+ # - A 2-element array: [value, label] renders
+ # - A single value: "text" renders
+ # - nil: renders an empty
div do
Field(:contact).label { "Would you like us to spam you to death?" }
Field(:contact).select(
@@ -359,6 +362,43 @@ class SignupForm < Components::Form
end
end
+ # Pass nil as first argument to add a blank option at the start
+ div do
+ Field(:country).label { "Select your country" }
+ Field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"])
+ end
+
+ # Multiple select with multiple: true
+ # - Adds the HTML 'multiple' attribute
+ # - Appends [] to the field name (role_ids becomes role_ids[])
+ # - Includes a hidden input to handle empty submissions
+ div do
+ Field(:role_ids).label { "Select roles" }
+ Field(:role_ids).select(
+ [[1, "Admin"], [2, "Editor"], [3, "Viewer"]],
+ multiple: true
+ )
+ end
+
+ # Combine multiple: true with nil for blank option
+ div do
+ Field(:tag_ids).label { "Select tags (optional)" }
+ Field(:tag_ids).select(
+ nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"],
+ multiple: true
+ )
+ end
+
+ # Select options can also be ActiveRecord relations
+ # The relation is passed as a single argument (not splatted)
+ # OptionMapper extracts the primary key and joins other attributes for the label
+ div do
+ Field(:author_id).label { "Select author" }
+ # For User.select(:id, :name), renders
+ # where id=1 is the primary key and "Alice" is the name attribute
+ Field(:author_id).select(User.select(:id, :name))
+ end
+
div do
Field(:agreement).label { "Check this box if you agree to give us your first born child" }
Field(:agreement).checkbox(checked: true)
@@ -414,8 +454,8 @@ Then, just like you did in your Erb, you create the form:
```ruby
class Admin::Users::Form < AdminForm
def view_template(&)
- labeled field(:name).tooltip_input
- labeled field(:email).tooltip_input(type: :email)
+ labeled Field(:name).tooltip_input
+ labeled Field(:email).tooltip_input(type: :email)
submit "Save"
end
diff --git a/lib/superform/dom.rb b/lib/superform/dom.rb
index befaf14..e74c12b 100644
--- a/lib/superform/dom.rb
+++ b/lib/superform/dom.rb
@@ -29,6 +29,12 @@ def name
names.map { |name| "[#{name}]" }.unshift(root).join
end
+ # Returns the name with `[]` appended for array/multiple value fields.
+ # Used by multiple selects, checkbox groups, etc.
+ def array_name
+ "#{name}[]"
+ end
+
# Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
def inspect
""
diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb
index 8979591..79425c0 100644
--- a/lib/superform/rails/components/select.rb
+++ b/lib/superform/rails/components/select.rb
@@ -2,22 +2,54 @@ module Superform
module Rails
module Components
class Select < Field
- def initialize(*, collection: [], **, &)
+ def initialize(
+ *,
+ options: [],
+ collection: nil,
+ multiple: false,
+ **,
+ &
+ )
super(*, **, &)
- @collection = collection
+
+ # Handle deprecated collection parameter
+ if collection && options.empty?
+ warn "[DEPRECATION] Superform::Rails::Components::Select: " \
+ "`collection:` keyword is deprecated and will be removed. " \
+ "Use positional arguments instead: field.select([1, 'A'], [2, 'B'])"
+ options = collection
+ end
+
+ @options = options
+ @multiple = multiple
end
- def view_template(&options)
+ def view_template(&block)
+ # Hidden input ensures a value is sent even when all options are
+ # deselected in a multiple select
+ if @multiple
+ hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : dom.array_name
+ input(type: "hidden", name: hidden_name, value: "")
+ end
+
if block_given?
- select(**attributes, &options)
+ select(**attributes, &block)
else
- select(**attributes) { options(*@collection) }
+ select(**attributes) do
+ options(*@options)
+ end
end
end
def options(*collection)
+ # Handle both single values and arrays (for multiple selects)
+ selected_values = Array(field.value)
map_options(collection).each do |key, value|
- option(selected: field.value == key, value: key) { value }
+ if key.nil?
+ blank_option
+ else
+ option(selected: selected_values.include?(key), value: key) { value }
+ end
end
end
@@ -37,6 +69,18 @@ def false_option(&)
def map_options(collection)
OptionMapper.new(collection)
end
+
+ def field_attributes
+ attrs = super
+ if @multiple
+ # Only append [] if the field doesn't already have a Field parent
+ # (which would mean it's already in a collection and has [] notation)
+ name = field.parent.is_a?(Superform::Field) ? attrs[:name] : dom.array_name
+ attrs.merge(multiple: true, name: name)
+ else
+ attrs
+ end
+ end
end
end
end
diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb
index f82325f..57904ab 100644
--- a/lib/superform/rails/field.rb
+++ b/lib/superform/rails/field.rb
@@ -45,8 +45,14 @@ def textarea(**attributes)
Components::Textarea.new(field, attributes:)
end
- def select(*collection, **attributes, &)
- Components::Select.new(field, attributes:, collection:, &)
+ def select(*options, multiple: false, **attributes, &)
+ Components::Select.new(
+ field,
+ attributes:,
+ options:,
+ multiple:,
+ &
+ )
end
def errors
diff --git a/spec/superform/rails/components/select_collection_integration_spec.rb b/spec/superform/rails/components/select_collection_integration_spec.rb
new file mode 100644
index 0000000..7a80555
--- /dev/null
+++ b/spec/superform/rails/components/select_collection_integration_spec.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+RSpec.describe "Select in Collection Integration", type: :view do
+ # Set up test database for collection models
+ before(:all) do
+ ActiveRecord::Schema.define do
+ create_table :orders, force: true do |t|
+ t.integer :item_id
+ t.string :tag_ids
+ end
+ end unless ActiveRecord::Base.connection.table_exists?(:orders)
+ end
+
+ after(:all) do
+ ActiveRecord::Schema.define do
+ drop_table :orders if ActiveRecord::Base.connection.table_exists?(:orders)
+ end
+ end
+
+ # Test model for collection (array of objects)
+ class Order < ActiveRecord::Base
+ # Serialize tag_ids as JSON array for multiple select testing
+ serialize :tag_ids, coder: JSON
+ end
+
+ describe "single select in collection" do
+ let(:initial_orders) do
+ [
+ Order.new(item_id: 1),
+ Order.new(item_id: 2)
+ ]
+ end
+ let(:model) do
+ User.new(first_name: "Test", email: "test@example.com").tap do |user|
+ orders_list = initial_orders
+ user.define_singleton_method(:orders) { @orders ||= orders_list }
+ user.define_singleton_method(:orders=) { |val| @orders = val }
+ end
+ end
+ let(:form) { Superform::Rails::Form.new(model, action: "/users") }
+ let(:item_options) { [[1, "Coffee"], [2, "Tea"], [3, "Juice"]] }
+
+ it "renders select with collection notation" do
+ html = render(form) do |f|
+ orders_collection = f.collection(:orders)
+ orders_collection.each do |order_namespace|
+ f.render order_namespace.field(:item_id).select(*item_options)
+ end
+ end
+
+ # Collection uses model_name[collection_name][index][field_name] notation
+ expect(html).to include('name="user[orders][0][item_id]"')
+ expect(html).to include('name="user[orders][1][item_id]"')
+ end
+
+ it "pre-selects options based on collection values" do
+ html = render(form) do |f|
+ orders_collection = f.collection(:orders)
+ orders_collection.each do |order_namespace|
+ f.render order_namespace.field(:item_id).select(*item_options)
+ end
+ end
+
+ # First order should have item_id=1 (Coffee) selected
+ first_select = html.match(/