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..6f13971 100644
--- a/README.md
+++ b/README.md
@@ -359,11 +359,67 @@ class SignupForm < Components::Form
end
end
+ # Radio buttons - for single-select from multiple options
+ div do
+ Field(:plan).label(for: false) { "Choose your plan" }
+ # Pass options as positional arguments
+ Field(:plan).radio(
+ ["free", "Free Plan"], #
+ ["pro", "Pro Plan"], #
+ ["enterprise", "Enterprise"] #
+ )
+ end
+
+ # Or render individual radio buttons with custom markup
+ div do
+ Field(:gender).label(for: false) { "Gender" }
+ Field(:gender).radio do |r|
+ div { r.option("m") { "Male" } }
+ div { r.option("f") { "Female" } }
+ div { r.option("o") { "Other" } }
+ end
+ end
+
+ # Boolean checkbox - single true/false toggle
div do
Field(:agreement).label { "Check this box if you agree to give us your first born child" }
Field(:agreement).checkbox(checked: true)
end
+ # Checkbox array - for multi-select from multiple options
+ div do
+ Field(:role_ids).label(for: false) { "Select your roles" }
+ # Pass options as positional arguments (similar to radio)
+ Field(:role_ids).checkbox(
+ [1, "Admin"], #
+ [2, "Editor"], #
+ [3, "Viewer"] #
+ )
+ end
+
+ # Or render individual checkboxes with custom markup
+ div do
+ Field(:feature_ids).label(for: false) { "Enable features" }
+ Field(:feature_ids).checkbox do |c|
+ div { c.option(1) { "Dark Mode" } }
+ div { c.option(2) { "Notifications" } }
+ div { c.option(3) { "Auto-save" } }
+ end
+ end
+
+ # Both radio and checkbox support ActiveRecord relations
+ div do
+ Field(:category_id).label(for: false) { "Select category" }
+ # Automatically uses id as value and name as label
+ Field(:category_id).radio(Category.select(:id, :name))
+ end
+
+ div do
+ Field(:tag_ids).label(for: false) { "Select tags" }
+ # Automatically uses id as value and name as label
+ Field(:tag_ids).checkbox(Tag.select(:id, :name))
+ end
+
render button { "Submit" }
end
end
diff --git a/lib/superform/rails/components/checkbox.rb b/lib/superform/rails/components/checkbox.rb
index 0c40f73..ba0d0e1 100644
--- a/lib/superform/rails/components/checkbox.rb
+++ b/lib/superform/rails/components/checkbox.rb
@@ -2,17 +2,76 @@ module Superform
module Rails
module Components
class Checkbox < Field
- def view_template(&)
- # Rails has a hidden and checkbox input to deal with sending back a value
- # to the server regardless of if the input is checked or not.
- input(name: dom.name, type: :hidden, value: "0")
- # The hard coded keys need to be in here so the user can't overrite them.
- input(type: :checkbox, value: "1", **attributes)
+ def initialize(field, *option_list, **, &)
+ super(field, **, &)
+ @options = option_list
end
- def field_attributes
- { id: dom.id, name: dom.name, checked: field.value }
+ def view_template(&block)
+ if array_mode? || block_given?
+ # Array mode: render multiple checkboxes
+ if block_given?
+ yield self
+ else
+ options(*@options)
+ end
+ else
+ # Boolean mode: single checkbox with hidden field
+ # Rails has a hidden and checkbox input to deal with sending back
+ # a value to the server regardless of if the input is checked or not.
+ input(name: dom.name, type: :hidden, value: "0")
+ # The hard coded keys need to be in here so the user can't overrite them.
+ input(type: :checkbox, value: "1", **attributes)
+ end
end
+
+ # Array mode methods
+ def options(*option_list)
+ map_options(option_list).each do |value, label|
+ option(value) { label }
+ end
+ end
+
+ def option(value, &block)
+ label do
+ input(
+ **attributes,
+ type: :checkbox,
+ id: "#{dom.id}_#{value}",
+ name: "#{dom.name}[]",
+ value: value.to_s,
+ checked: checked_in_array?(value)
+ )
+ plain(yield) if block_given?
+ end
+ end
+
+ protected
+ def array_mode?
+ @options.any?
+ end
+
+ def map_options(option_list)
+ OptionMapper.new(option_list)
+ end
+
+ def checked_in_array?(value)
+ # Checkbox arrays are multi-select, so field.value should be an array
+ field_value = field.value
+ return false if field_value.nil?
+
+ field_value = [field_value] unless field_value.is_a?(Array)
+ field_value.map(&:to_s).include?(value.to_s)
+ end
+
+ def field_attributes
+ if array_mode?
+ # option method handles all attributes explicitly
+ {}
+ else
+ { id: dom.id, name: dom.name, checked: field.value }
+ end
+ end
end
end
end
diff --git a/lib/superform/rails/components/label.rb b/lib/superform/rails/components/label.rb
index 40f853f..788f195 100644
--- a/lib/superform/rails/components/label.rb
+++ b/lib/superform/rails/components/label.rb
@@ -8,7 +8,19 @@ def view_template(&content)
end
def field_attributes
- { for: dom.id }
+ # Only include 'for' attribute if explicitly provided or default
+ # Skip it if set to false/nil to avoid invalid HTML
+ attrs = {}
+ for_value = @attributes&.fetch(:for, :default)
+
+ if for_value == :default
+ attrs[:for] = dom.id
+ elsif for_value
+ attrs[:for] = for_value
+ end
+ # If for_value is false/nil, skip the attribute entirely
+
+ attrs
end
def label_text
diff --git a/lib/superform/rails/components/radio.rb b/lib/superform/rails/components/radio.rb
new file mode 100644
index 0000000..dbbcb8f
--- /dev/null
+++ b/lib/superform/rails/components/radio.rb
@@ -0,0 +1,53 @@
+module Superform
+ module Rails
+ module Components
+ class Radio < Field
+ def initialize(field, *option_list, **, &)
+ super(field, **, &)
+ @options = option_list
+ end
+
+ def view_template(&block)
+ if block_given?
+ yield self
+ else
+ options(*@options)
+ end
+ end
+
+ def options(*option_list)
+ map_options(option_list).each do |value, label|
+ option(value) { label }
+ end
+ end
+
+ def option(value, &block)
+ label do
+ input(
+ **attributes,
+ type: :radio,
+ id: "#{dom.id}_#{value}",
+ value: value.to_s,
+ checked: checked?(value)
+ )
+ plain(yield) if block_given?
+ end
+ end
+
+ protected
+ def map_options(option_list)
+ OptionMapper.new(option_list)
+ end
+
+ def checked?(value)
+ # Radio buttons are single-select, so field.value should never be an array
+ field.value.to_s == value.to_s
+ end
+
+ def field_attributes
+ { name: dom.name }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb
index f82325f..8d3a60e 100644
--- a/lib/superform/rails/field.rb
+++ b/lib/superform/rails/field.rb
@@ -33,8 +33,10 @@ def input(**attributes)
Components::Input.new(field, attributes:)
end
- def checkbox(**attributes)
- Components::Checkbox.new(field, attributes:)
+ def checkbox(*args, **attributes)
+ # Treat as collection if args provided (including single ActiveRecord::Relation)
+ # Otherwise treat as boolean checkbox (single true/false)
+ Components::Checkbox.new(field, *args, attributes:)
end
def label(**attributes, &)
@@ -137,8 +139,22 @@ def file(*, **, &)
input(*, **, type: :file, &)
end
- def radio(value, *, **, &)
- input(*, **, type: :radio, value: value, &)
+ def radio(*args, **attributes, &block)
+ # If multiple args or first arg is an array, treat as collection
+ if args.length > 1 || (args.length == 1 && args.first.is_a?(Array))
+ Components::Radio.new(field, *args, attributes:, &block)
+ # If single arg is an ActiveRecord::Relation, treat as collection
+ elsif args.length == 1 && defined?(ActiveRecord::Relation) &&
+ args.first.is_a?(ActiveRecord::Relation)
+ Components::Radio.new(field, *args, attributes:, &block)
+ # No args but block given - allow custom rendering
+ elsif args.empty? && block
+ Components::Radio.new(field, attributes:, &block)
+ # No args or single non-collection arg - error
+ else
+ raise ArgumentError,
+ "radio requires multiple options (e.g., radio(['a', 'A'], ['b', 'B']))"
+ end
end
# Rails compatibility aliases
diff --git a/spec/superform/rails/components/checkbox_collection_integration_spec.rb b/spec/superform/rails/components/checkbox_collection_integration_spec.rb
new file mode 100644
index 0000000..f6ea79a
--- /dev/null
+++ b/spec/superform/rails/components/checkbox_collection_integration_spec.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+RSpec.describe "Checkbox in Collection Integration", type: :view do
+ # Set up test database for collection models
+ before(:all) do
+ ActiveRecord::Schema.define do
+ create_table :pizza_orders, force: true do |t|
+ t.string :toppings
+ end
+ end unless ActiveRecord::Base.connection.table_exists?(:pizza_orders)
+ end
+
+ after(:all) do
+ ActiveRecord::Schema.define do
+ drop_table :pizza_orders if ActiveRecord::Base.connection.table_exists?(:pizza_orders)
+ end
+ end
+
+ # Test model for collection (array of objects)
+ class PizzaOrder < ActiveRecord::Base
+ self.table_name = "pizza_orders"
+ # Serialize toppings as array for multi-select checkboxes
+ serialize :toppings, coder: JSON
+ end
+
+ describe "collection (array of objects with multi-select)" do
+ let(:initial_orders) do
+ [
+ PizzaOrder.new(toppings: ["cheese", "pepperoni"]),
+ PizzaOrder.new(toppings: ["mushrooms"])
+ ]
+ end
+ let(:model) do
+ # Simulates a model with has_many association
+ # Using User with a mock orders association
+ orders_list = initial_orders # capture for closure
+ User.new(first_name: "Test", email: "test@example.com").tap do |user|
+ 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(:topping_options) do
+ [
+ ["cheese", "Cheese"],
+ ["pepperoni", "Pepperoni"],
+ ["mushrooms", "Mushrooms"],
+ ["olives", "Olives"]
+ ]
+ end
+
+ it "renders checkboxes 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(:toppings).checkbox(*topping_options)
+ end
+ end
+
+ # Collection uses model_name[collection_name][index][field_name][] notation
+ # The final [] is for checkbox array submission
+ expect(html).to include('name="user[orders][0][toppings][]"')
+ expect(html).to include('name="user[orders][1][toppings][]"')
+ expect(html.scan(/type="checkbox"/).count).to eq(8) # 4 checkboxes × 2 orders
+ end
+
+ it "pre-selects checkboxes 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(:toppings).checkbox(*topping_options)
+ end
+ end
+
+ # Should have exactly 3 checked checkboxes total
+ # First order: cheese, pepperoni (2 checked)
+ # Second order: mushrooms (1 checked)
+ expect(html.scan(/checked/).count).to eq(3)
+
+ # "cheese" should be checked (first order)
+ expect(html).to match(/]*id="user_orders_0_toppings_cheese"[^>]*checked/)
+ # "pepperoni" should be checked (first order)
+ expect(html).to match(/]*id="user_orders_0_toppings_pepperoni"[^>]*checked/)
+ # "mushrooms" should be checked (second order)
+ expect(html).to match(/]*id="user_orders_1_toppings_mushrooms"[^>]*checked/)
+ end
+
+ it "does not check unselected options" do
+ html = render(form) do |f|
+ orders_collection = f.collection(:orders)
+ orders_collection.each do |order_namespace|
+ f.render order_namespace.field(:toppings).checkbox(*topping_options)
+ end
+ end
+
+ # "olives" should not be checked in either order
+ expect(html).not_to match(/]*id="user_orders_0_toppings_olives"[^>]*checked/)
+ expect(html).not_to match(/]*id="user_orders_1_toppings_olives"[^>]*checked/)
+
+ # "mushrooms" should not be checked in first order
+ expect(html).not_to match(/]*id="user_orders_0_toppings_mushrooms"[^>]*checked/)
+ end
+
+ it "works with submitted params from collection" do
+ # Simulate Rails params after form submission
+ # Collection checkboxes submit as:
+ # { "user" => { "orders" => [
+ # { "toppings" => ["olives", "mushrooms"] },
+ # { "toppings" => ["cheese", "pepperoni", "olives"] }
+ # ] } }
+ submitted_model = User.new(first_name: "Test", email: "test@example.com").tap do |user|
+ user.define_singleton_method(:orders) do
+ [
+ PizzaOrder.new(toppings: ["olives", "mushrooms"]),
+ PizzaOrder.new(toppings: ["cheese", "pepperoni", "olives"])
+ ]
+ end
+ end
+ submitted_form = Superform::Rails::Form.new(submitted_model, action: "/users")
+
+ html = render(submitted_form) do |f|
+ orders_collection = f.collection(:orders)
+ orders_collection.each do |order_namespace|
+ f.render order_namespace.field(:toppings).checkbox(*topping_options)
+ end
+ end
+
+ # Should have exactly 5 checked checkboxes
+ # First order: olives, mushrooms (2 checked)
+ # Second order: cheese, pepperoni, olives (3 checked)
+ expect(html.scan(/checked/).count).to eq(5)
+
+ # First order should have "olives" and "mushrooms" checked
+ expect(html).to match(/]*id="user_orders_0_toppings_olives"[^>]*checked/)
+ expect(html).to match(/]*id="user_orders_0_toppings_mushrooms"[^>]*checked/)
+
+ # Second order should have "cheese", "pepperoni", "olives" checked
+ expect(html).to match(/]*id="user_orders_1_toppings_cheese"[^>]*checked/)
+ expect(html).to match(/]*id="user_orders_1_toppings_pepperoni"[^>]*checked/)
+ expect(html).to match(/]*id="user_orders_1_toppings_olives"[^>]*checked/)
+ end
+
+ it "wraps each checkbox in a label for clickability" do
+ html = render(form) do |f|
+ orders_collection = f.collection(:orders)
+ orders_collection.each do |order_namespace|
+ f.render order_namespace.field(:toppings).checkbox(*topping_options)
+ end
+ end
+
+ # Each checkbox should be wrapped in a label
+ # 4 options × 2 orders = 8 labels
+ expect(html.scan(/