Skip to content

Commit 0ebf93a

Browse files
committed
Add radios/checkboxes Choices API and hash option support
Introduce field(:plan).radios(...) and field(:roles).checkboxes(...) for declarative radio and checkbox groups. Each yields a Choice with .radio, .checkbox, .label, .value, .text. Accepts the same option formats as select (arrays, single values, ActiveRecord relations) plus hashes (e.g. {1 => "Basic", 2 => "Pro"}). Refactor DOM#id to take no arguments, add DOM.join and DOM::DELIMITER for centralized id construction. Radio and Checkbox components now use index-based ids via DOM.join, with backward-compatible defaults.
1 parent 3c6bcd4 commit 0ebf93a

14 files changed

Lines changed: 274 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
### Added
44

5+
- **Radio and checkbox groups** via `field(:plan).radios(...)` and `field(:roles).checkboxes(...)`.
6+
Accepts the same option formats as `select`. Each iteration yields a `Choice` with
7+
`.radio`, `.checkbox`, `.label`, `.value`, `.text`.
8+
- **Hash options** for `select`, `radios`, and `checkboxes` — e.g. `radios(1 => "Basic", 2 => "Pro")`.
59
- **Radio component** with `field(:gender).radio("male")` API. Automatically handles name, value, and checked state. Each radio gets a unique DOM id based on its value (e.g. `user_gender_male`).
610
- **Checkbox collection support** — three modes:
711
- **Boolean** (on/off toggle): `Field(:featured).checkbox` renders with hidden "0" input

README.md

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ class SignupForm < Components::Form
338338
# Selects accept options as positional arguments. Each option can be:
339339
# - A 2-element array: [value, label] renders <option value="value">label</option>
340340
# - A single value: "text" renders <option value="text">text</option>
341+
# - A hash: {1 => "Admin", 2 => "Editor"} maps ids to labels
341342
# - nil: renders an empty <option></option>
342343
div do
343344
Field(:contact).label { "Would you like us to spam you to death?" }
@@ -406,34 +407,40 @@ class SignupForm < Components::Form
406407
Field(:agreement).checkbox(checked: true)
407408
end
408409

409-
# Checkbox groups: loop over all possible options. Superform handles
410-
# the name (with []), value, checked state, and unique ids. Each
411-
# checkbox gets an id like "role_ids_1", "role_ids_2", etc.
410+
# Radio groups: use radios(...) with the same option formats as select.
411+
# Each iteration yields a Choice with .radio, .label, .value, .text.
412+
# Ids are index-based: "plan_id_0", "plan_id_1", etc.
412413
fieldset do
413-
legend { "Roles" }
414-
Role.all.each do |role|
415-
label(for: field(:role_ids).dom.id(role.id)) do
416-
Field(:role_ids).checkbox(value: role.id)
414+
legend { "Plan" }
415+
field(:plan_id).radios([1, "Basic"], [2, "Pro"], [3, "Enterprise"]).each do |choice|
416+
render choice.label {
417+
render choice.radio
417418
whitespace
418-
plain role.name
419-
end
419+
plain choice.text
420+
}
420421
end
421422
end
422423

423-
# Radio groups: iterate your options and call radio(value) on the field.
424-
# Superform handles the name, value, checked state, and unique ids automatically.
425-
# Each radio gets an id like "plan_id_1", "plan_id_2", etc.
424+
# Checkbox groups: use checkboxes(...) with the same option formats.
425+
# Handles name[] and checked state automatically.
426426
fieldset do
427-
legend { "Plan" }
428-
Plan.all.each do |plan|
429-
label(for: field(:plan_id).dom.id(plan.id)) do
430-
Field(:plan_id).radio(plan.id)
427+
legend { "Roles" }
428+
field(:role_ids).checkboxes([1, "Admin"], [2, "Editor"], [3, "Viewer"]).each do |choice|
429+
render choice.label {
430+
render choice.checkbox
431431
whitespace
432-
plain plan.name
433-
end
432+
plain choice.text
433+
}
434434
end
435435
end
436436

437+
# Options also accept hashes: radios(1 => "Basic", 2 => "Pro")
438+
# or ActiveRecord relations: radios(Plan.select(:id, :name))
439+
440+
# Low-level API: field(:gender).radio("male") and
441+
# field(:role_ids).checkbox(value: 1) are still available
442+
# for full control over iteration and ids.
443+
437444
render button { "Submit" }
438445
end
439446
end

lib/superform/dom.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ module Superform
33
# norms that were established by Rails. These can be used outsidef or Rails in
44
# other Ruby web frameworks since it has now dependencies on Rails.
55
class DOM
6+
DELIMITER = "_"
7+
8+
def self.join(*segments)
9+
segments.join(DELIMITER)
10+
end
11+
612
def initialize(field:)
713
@field = field
814
end
@@ -16,8 +22,8 @@ def value
1622
# Walks from the current node to the parent node, grabs the names, and seperates
1723
# them with a `_` for a DOM ID. One limitation of this approach is if multiple forms
1824
# exist on the same page, the ID may be duplicate.
19-
def id(*suffixes)
20-
(lineage.map(&:key) + suffixes).join("_")
25+
def id
26+
self.class.join(*lineage.map(&:key))
2127
end
2228

2329
# The `name` attribute of a node, which is influenced by Rails (not sure where Rails got

lib/superform/field.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def kit(form)
9595

9696
def self.copy_field_methods_to_kit(field_class, kit_class)
9797
base_methods = (Object.instance_methods + Node.instance_methods +
98-
[:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
98+
[:dom, :value, :serialize, :assign, :collection, :field, :kit, :radios, :checkboxes]).to_set
9999

100100
field_class.instance_methods(false).each do |method_name|
101101
next if method_name.to_s.end_with?('=')
@@ -113,7 +113,7 @@ def self.add_method_to_kit(method_name, kit_class)
113113
return if method_name.to_s.end_with?('=')
114114

115115
base_methods = (Object.instance_methods + Node.instance_methods +
116-
[:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
116+
[:dom, :value, :serialize, :assign, :collection, :field, :kit, :radios, :checkboxes]).to_set
117117
return if base_methods.include?(method_name)
118118
return if kit_class.method_defined?(method_name)
119119

lib/superform/rails/choices.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
module Superform
2+
module Rails
3+
class Choices
4+
include Enumerable
5+
6+
def initialize(field:, options:)
7+
@field = field
8+
@options = options
9+
end
10+
11+
def each(&)
12+
OptionMapper.new(@options).each_with_index do |(value, text), index|
13+
yield Choice.new(field: @field, value:, text:, index:)
14+
end
15+
end
16+
17+
class Choice
18+
attr_reader :value, :text
19+
20+
def initialize(field:, value:, text:, index:)
21+
@field = field
22+
@value = value
23+
@text = text
24+
@index = index
25+
end
26+
27+
def radio(**attributes)
28+
@field.radio(@value, index: @index, **attributes)
29+
end
30+
31+
def checkbox(**attributes)
32+
@field.checkbox(value: @value, index: @index, **attributes)
33+
end
34+
35+
def label(**attributes, &)
36+
Components::Label.new(@field, for: DOM.join(@field.dom.id, @index), **attributes, &)
37+
end
38+
end
39+
end
40+
end
41+
end

lib/superform/rails/components/checkbox.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ module Superform
22
module Rails
33
module Components
44
class Checkbox < Field
5+
def initialize(field, index: nil, **attributes)
6+
super(field, **attributes)
7+
@index = index
8+
end
9+
510
def view_template(&)
611
if boolean?
712
# Rails convention: hidden input ensures a value is sent even when unchecked
@@ -20,7 +25,7 @@ def field_attributes
2025
elsif collection?
2126
{ id: dom.id, name: dom.name, checked: true }
2227
else
23-
{ id: dom.id(@attributes[:value]), name: dom.array_name, checked: Array(field.value).include?(@attributes[:value]) }
28+
{ id: DOM.join(dom.id, @index || @attributes[:value]), name: dom.array_name, checked: Array(field.value).include?(@attributes[:value]) }
2429
end
2530
end
2631

lib/superform/rails/components/radio.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ module Superform
22
module Rails
33
module Components
44
class Radio < Field
5-
def initialize(field, value:, **attributes)
5+
def initialize(field, value:, index: value, **attributes)
66
super(field, **attributes)
77
@value = value
8+
@index = index
89
end
910

1011
def view_template(&)
1112
input(type: :radio, **attributes)
1213
end
1314

1415
def field_attributes
15-
{ id: dom.id(@value), name: dom.name, value: @value, checked: field.value == @value }
16+
{ id: DOM.join(dom.id, @index), name: dom.name, value: @value, checked: field.value == @value }
1617
end
1718
end
1819
end

lib/superform/rails/field.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ def input(**attributes)
3333
Components::Input.new(field, **attributes)
3434
end
3535

36-
def checkbox(**attributes)
37-
Components::Checkbox.new(field, **attributes)
36+
def checkbox(index: nil, **attributes)
37+
Components::Checkbox.new(field, index:, **attributes)
3838
end
3939

4040
def label(**attributes, &)
@@ -143,8 +143,16 @@ def file(*, **)
143143
input(*, **, type: :file)
144144
end
145145

146-
def radio(value, **attributes)
147-
Components::Radio.new(field, value:, **attributes)
146+
def radio(value, index: value, **attributes)
147+
Components::Radio.new(field, value:, index:, **attributes)
148+
end
149+
150+
def radios(*options)
151+
Choices.new(field: self, options:)
152+
end
153+
154+
def checkboxes(*options)
155+
Choices.new(field: self, options:)
148156
end
149157

150158
# Rails compatibility aliases

lib/superform/rails/option_mapper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ def each(&options)
1313
case object
1414
in ActiveRecord::Relation => relation
1515
active_record_relation_options_enumerable(relation).each(&options)
16+
in Hash => hash
17+
hash.each { |id, value| options.call id, value }
1618
in id, value
1719
options.call id, value
1820
in value

spec/superform/dom_spec.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,15 @@ def build_lineage(**lineage)
7373
expect(field.dom.id).to eq("grandparent_bars_baz")
7474
end
7575

76-
it "appends suffixes to the id" do
77-
field = build_lineage(parent: Superform::Namespace, child: Superform::Field)
78-
expect(field.dom.id("male")).to eq("parent_child_male")
79-
expect(field.dom.id(1)).to eq("parent_child_1")
76+
end
77+
78+
describe ".join" do
79+
it "joins segments with the delimiter" do
80+
expect(Superform::DOM.join("parent", "child", "male")).to eq("parent_child_male")
81+
end
82+
83+
it "converts non-string segments" do
84+
expect(Superform::DOM.join("parent", 1)).to eq("parent_1")
8085
end
8186
end
8287
end

0 commit comments

Comments
 (0)