Skip to content

Commit 6d6e5d3

Browse files
committed
Make radios/checkboxes renderable Phlex components
radios/checkboxes now return Phlex components instead of a Choices enumerable. Without a block they render default label+input markup; with a block they yield each Choice for custom layout. Kit auto-renders them as one-liners. Reorganize into Choices module: Choice::Choice holds per-option state, Choice::Mapper (renamed from OptionMapper) maps args to (value, text) pairs. Choice exposes a unified `input` method instead of separate radio/checkbox methods — the type is set at construction.
1 parent fc58de2 commit 6d6e5d3

12 files changed

Lines changed: 345 additions & 184 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
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`. Auto-detects Rails enums when called
8-
with no arguments — options are generated from `Model.defined_enums` with humanized labels.
5+
- **Radio and checkbox groups** via `Field(:plan).radios(...)` and `Field(:roles).checkboxes(...)`.
6+
Now return renderable Phlex components (like `input`, `select`) so they work as one-liners
7+
via Kit. Without a block, renders default `<label><input> Text</label>` markup per choice.
8+
With a block, yields each `Choice` for custom markup — choice methods (`.input`,
9+
`.label`) render directly into the component's output. Accepts the same option formats as
10+
`select`. Auto-detects Rails enums when called with no arguments.
911
`choice.label` without a block defaults to rendering `choice.text`.
1012
- **Hash options** for `select`, `radios`, and `checkboxes` — e.g. `radios(1 => "Basic", 2 => "Pro")`.
1113
- **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`).

README.md

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,40 @@ Then render it from Erb.
222222

223223
Much better!
224224

225+
### Customizing radios and checkboxes
226+
227+
`radios` and `checkboxes` render sensible defaults out of the box, but you can subclass them to match your design system. Override `view_template` to change the default markup — the block form still works for one-off customizations.
228+
229+
```ruby
230+
class Components::Form < Superform::Rails::Form
231+
class MyRadios < Superform::Rails::Components::Radios
232+
def view_template(&block)
233+
choices.each do |choice|
234+
if block
235+
yield choice
236+
else
237+
div(class: "radio-option") do
238+
render choice.build_radio(class: "radio-input")
239+
label(for: DOM.join(dom.id, choice.index), class: "radio-label") do
240+
plain choice.text
241+
end
242+
end
243+
end
244+
end
245+
end
246+
end
247+
248+
class Field < Field
249+
def radios(*options, **attributes, &block)
250+
options = enum_options if options.empty?
251+
MyRadios.new(field, options:, **attributes, &block)
252+
end
253+
end
254+
end
255+
```
256+
257+
Now every `Field(:status).radios` in your app gets the custom markup. Individual forms can still pass a block for one-off layouts.
258+
225259
## Namespaces & Collections
226260

227261
Superform uses a different syntax for namespacing and collections than Rails, which can be a bit confusing since the same terminology is used but the application is slightly different.
@@ -370,7 +404,7 @@ class JobPostingForm < Components::Form
370404
end
371405

372406
# ActiveRecord relations work as select options too.
373-
# OptionMapper uses the primary key as value and joins remaining
407+
# Choice::Mapper uses the primary key as value and joins remaining
374408
# attributes for the label.
375409
div do
376410
Field(:hiring_manager_id).label { "Hiring manager" }
@@ -386,11 +420,15 @@ class JobPostingForm < Components::Form
386420

387421
# Radio group auto-detected from a Rails enum.
388422
# Given: enum :employment_type, full_time: 0, part_time: 1, contract: 2
423+
# One-liner — renders <label><input type="radio"> Text</label> per choice.
424+
Field(:employment_type).radios
425+
426+
# Block form — full control over each choice's markup.
389427
fieldset do
390428
legend { "Employment type" }
391-
field(:employment_type).radios.each do |choice|
392-
render choice.label {
393-
render choice.radio
429+
Field(:employment_type).radios do |choice|
430+
choice.label {
431+
choice.input
394432
whitespace
395433
plain choice.text # "Full time", "Part time", "Contract"
396434
}
@@ -399,20 +437,29 @@ class JobPostingForm < Components::Form
399437

400438
# You can also pass explicit options to override enum detection.
401439
# Accepts arrays, single values, hashes, or ActiveRecord relations.
402-
# field(:employment_type).radios("full_time" => "Full-time", ...)
440+
# Field(:employment_type).radios("full_time" => "Full-time", ...)
403441

404442
# Checkbox groups. Same option formats, same Choice API.
405443
# Handles name[], checked state, and unique ids automatically.
444+
# One-liner:
445+
Field(:benefit_ids).checkboxes(
446+
[1, "Health insurance"],
447+
[2, "Dental & vision"],
448+
[3, "401(k)"],
449+
[4, "Stock options"]
450+
)
451+
452+
# Block form:
406453
fieldset do
407454
legend { "Benefits" }
408-
field(:benefit_ids).checkboxes(
455+
Field(:benefit_ids).checkboxes(
409456
[1, "Health insurance"],
410457
[2, "Dental & vision"],
411458
[3, "401(k)"],
412459
[4, "Stock options"]
413-
).each do |choice|
414-
render choice.label {
415-
render choice.checkbox
460+
) do |choice|
461+
choice.label {
462+
choice.input
416463
whitespace
417464
plain choice.text
418465
}
@@ -544,12 +591,15 @@ Here's a checkbox group in Rails vs Superform. In Rails you need to manually wir
544591
```
545592

546593
```ruby
547-
# Superform
594+
# Superform — one-liner
595+
Field(:benefit_ids).checkboxes(Benefit.select(:id, :name))
596+
597+
# Or with custom markup
548598
fieldset do
549599
legend { "Benefits" }
550-
field(:benefit_ids).checkboxes(Benefit.select(:id, :name)).each do |choice|
551-
render choice.label {
552-
render choice.checkbox
600+
Field(:benefit_ids).checkboxes(Benefit.select(:id, :name)) do |choice|
601+
choice.label {
602+
choice.input
553603
whitespace
554604
plain choice.text
555605
}

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, :radios, :checkboxes]).to_set
98+
[:dom, :value, :serialize, :assign, :collection, :field, :kit]).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, :radios, :checkboxes]).to_set
116+
[:dom, :value, :serialize, :assign, :collection, :field, :kit]).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: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,6 @@
11
module Superform
22
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, &block)
36-
label_text = @text
37-
block ||= proc { label_text }
38-
Components::Label.new(@field, for: DOM.join(@field.dom.id, @index), **attributes, &block)
39-
end
40-
end
3+
module Choices
414
end
425
end
436
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module Superform
2+
module Rails
3+
module Choices
4+
class Choice
5+
attr_reader :value, :text, :index
6+
7+
def initialize(component:, field:, value:, text:, index:, type:)
8+
@component = component
9+
@field = field
10+
@value = value
11+
@text = text
12+
@index = index
13+
@type = type
14+
end
15+
16+
def input(**attrs)
17+
@component.render build_input(**attrs)
18+
end
19+
20+
def label(**attrs, &block)
21+
label_text = @text
22+
block ||= proc { label_text }
23+
@component.render Components::Label.new(
24+
@field, for: DOM.join(@field.dom.id, @index), **attrs, &block
25+
)
26+
end
27+
28+
def build_input(**attrs)
29+
case @type
30+
when :radio
31+
Components::Radio.new(@field, value: @value, index: @index, **attrs)
32+
when :checkbox
33+
Components::Checkbox.new(@field, value: @value, index: @index, **attrs)
34+
end
35+
end
36+
end
37+
end
38+
end
39+
end
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+
module Choices
4+
# Maps collections of options into (value, text) pairs for form controls.
5+
# Accepts arrays, hashes, single values, and ActiveRecord relations.
6+
class Mapper
7+
include Enumerable
8+
9+
def initialize(collection)
10+
@collection = collection
11+
end
12+
13+
def each(&options)
14+
@collection.each do |object|
15+
case object
16+
in ActiveRecord::Relation => relation
17+
active_record_relation_options_enumerable(relation).each(&options)
18+
in Hash => hash
19+
hash.each { |id, value| options.call id, value }
20+
in id, value
21+
options.call id, value
22+
in value
23+
options.call value, value.to_s
24+
end
25+
end
26+
end
27+
28+
def active_record_relation_options_enumerable(relation)
29+
Enumerator.new do |collection|
30+
relation.each do |object|
31+
attributes = object.attributes
32+
id = attributes.delete(relation.primary_key)
33+
value = attributes.values.join(" ")
34+
collection << [ id, value ]
35+
end
36+
end
37+
end
38+
end
39+
end
40+
end
41+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
module Superform
2+
module Rails
3+
module Components
4+
class Checkboxes < Base
5+
def initialize(field, options: [], **attributes)
6+
super(field, **attributes)
7+
@options = options
8+
end
9+
10+
def view_template(&block)
11+
choices.each do |choice|
12+
if block
13+
yield choice
14+
else
15+
label(for: DOM.join(dom.id, choice.index)) do
16+
render choice.build_input
17+
whitespace
18+
plain choice.text
19+
end
20+
end
21+
end
22+
end
23+
24+
private
25+
26+
def choices
27+
Choices::Mapper.new(@options).each_with_index.map do |(value, text), index|
28+
Choices::Choice.new(component: self, field: @field, value:, text:, index:, type: :checkbox)
29+
end
30+
end
31+
32+
def field_attributes
33+
{}
34+
end
35+
end
36+
end
37+
end
38+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
module Superform
2+
module Rails
3+
module Components
4+
class Radios < Base
5+
def initialize(field, options: [], **attributes)
6+
super(field, **attributes)
7+
@options = options
8+
end
9+
10+
def view_template(&block)
11+
choices.each do |choice|
12+
if block
13+
yield choice
14+
else
15+
label(for: DOM.join(dom.id, choice.index)) do
16+
render choice.build_input
17+
whitespace
18+
plain choice.text
19+
end
20+
end
21+
end
22+
end
23+
24+
private
25+
26+
def choices
27+
Choices::Mapper.new(@options).each_with_index.map do |(value, text), index|
28+
Choices::Choice.new(component: self, field: @field, value:, text:, index:, type: :radio)
29+
end
30+
end
31+
32+
def field_attributes
33+
{}
34+
end
35+
end
36+
end
37+
end
38+
end

lib/superform/rails/components/select.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def false_option(&)
6767

6868
protected
6969
def map_options(collection)
70-
OptionMapper.new(collection)
70+
Choices::Mapper.new(collection)
7171
end
7272

7373
def field_attributes

lib/superform/rails/field.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,14 @@ def radio(value, index: value, **attributes)
147147
Components::Radio.new(field, value:, index:, **attributes)
148148
end
149149

150-
def radios(*options)
150+
def radios(*options, **attributes, &block)
151151
options = enum_options if options.empty?
152-
Choices.new(field: self, options:)
152+
Components::Radios.new(field, options:, **attributes, &block)
153153
end
154154

155-
def checkboxes(*options)
155+
def checkboxes(*options, **attributes, &block)
156156
options = enum_options if options.empty?
157-
Choices.new(field: self, options:)
157+
Components::Checkboxes.new(field, options:, **attributes, &block)
158158
end
159159

160160
# Rails compatibility aliases

0 commit comments

Comments
 (0)