Skip to content
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -252,6 +256,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
x86_64-darwin-21
x86_64-linux

DEPENDENCIES
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"], # <label><input type="radio" value="free">Free Plan</label>
["pro", "Pro Plan"], # <label><input type="radio" value="pro">Pro Plan</label>
["enterprise", "Enterprise"] # <label><input type="radio" value="enterprise">Enterprise</label>
)
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"], # <label><input type="checkbox" value="1">Admin</label>
[2, "Editor"], # <label><input type="checkbox" value="2">Editor</label>
[3, "Viewer"] # <label><input type="checkbox" value="3">Viewer</label>
)
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
Expand Down
75 changes: 67 additions & 8 deletions lib/superform/rails/components/checkbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/superform/rails/components/label.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions lib/superform/rails/components/radio.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 20 additions & 4 deletions lib/superform/rails/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, &)
Expand Down Expand Up @@ -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
Expand Down
Loading