Skip to content

Conversation

@nimmolo
Copy link

@nimmolo nimmolo commented Nov 14, 2025

This PR adds array mode to Checkbox and adds Radio components, enabling multi-select checkboxes and radio button groups.

What's New

  • Multi-select checkboxes - Array of checkbox options for selecting multiple values for the same field
  • Radio button groups - Single-select from multiple radio options for a field
  • Positional argument API - Pass options directly as arguments
  • Block rendering - Custom markup with option method
  • ActiveRecord relation support - Pass AR relations directly as the options (positional arg)
  • Consistent with Select - Uses option method for rendering custom content, as Select component currently does

Backward Compatibility

All existing boolean checkbox functionality remains unchanged.

New API

Checkbox Array Mode

Multi-select with positional arguments

field(:role_ids).checkbox(
  [1, "Admin"],
  [2, "Editor"],
  [3, "Viewer"]
)
# Renders:
# <label><input type="checkbox" name="role_ids[]" value="1">Admin</label>
# <label><input type="checkbox" name="role_ids[]" value="2">Editor</label>
# <label><input type="checkbox" name="role_ids[]" value="3">Viewer</label>

Custom rendering with block

field(:feature_ids).checkbox do |c|
  div(class: "feature-option") do
    c.option(1) { "Dark Mode" }
  end
  div(class: "feature-option") do
    c.option(2) { "Notifications" }
  end
end

With ActiveRecord relations

field(:tag_ids).checkbox(Tag.select(:id, :name))
# Automatically uses id as value, name as label

Boolean mode (unchanged)

field(:agreement).checkbox
# Still works exactly as before:
# <input type="hidden" name="agreement" value="0">
# <input type="checkbox" name="agreement" value="1">

Radio Array Mode

Single-select with positional arguments

field(:plan).radio(
  ["free", "Free Plan"],
  ["pro", "Pro Plan"],
  ["enterprise", "Enterprise"]
)
# Renders:
# <label><input type="radio" name="plan" value="free">Free Plan</label>
# <label><input type="radio" name="plan" value="pro">Pro Plan</label>
# <label><input type="radio" name="plan" value="enterprise">Enterprise</label>

Custom rendering with block

field(:gender).radio do |r|
  div(class: "radio-option") do
    r.option("m") { "Male" }
  end
  div(class: "radio-option") do
    r.option("f") { "Female" }
  end
  div(class: "radio-option") do
    r.option("o") { "Other" }
  end
end

With ActiveRecord relations

field(:category_id).radio(Category.select(:id, :name))

Usage Examples

Basic Form Example

class SignupForm < Components::Form
  def view_template
    # Multi-select checkboxes
    div do
      field(:role_ids).label(for: false) { "Select your roles" }
      field(:role_ids).checkbox(
        [1, "Admin"],
        [2, "Editor"],
        [3, "Viewer"]
      )
    end

    # Radio button group
    div do
      field(:plan).label(for: false) { "Choose your plan" }
      field(:plan).radio(
        ["free", "Free Plan"],
        ["pro", "Pro Plan"],
        ["enterprise", "Enterprise"]
      )
    end

    # Boolean checkbox (unchanged)
    div do
      field(:terms).label { "I agree to the terms" }
      field(:terms).checkbox
    end

    submit
  end
end

Advanced: Custom Markup

# Checkbox with custom styling
field(:features).checkbox do |c|
  div(class: "features-grid") do
    div(class: "feature-card") do
      c.option("dark_mode") do
        strong { "Dark Mode" }
        p { "Easy on the eyes" }
      end
    end
    div(class: "feature-card") do
      c.option("notifications") do
        strong { "Notifications" }
        p { "Stay updated" }
      end
    end
  end
end

# Radio with custom layout
field(:plan).radio do |r|
  div(class: "pricing-cards") do
    div(class: "card") do
      r.option("free") do
        h3 { "Free" }
        p { "$0/month" }
      end
    end
    div(class: "card") do
      r.option("pro") do
        h3 { "Pro" }
        p { "$10/month" }
      end
    end
  end
end

Option Format

Options accept multiple formats for flexibility:

# Array pairs [value, label]
field(:size).radio(
  ["s", "Small"],
  ["m", "Medium"],
  ["l", "Large"]
)

# Single values (used as both value and label)
field(:tags).checkbox(
  "Ruby",
  "Rails",
  "Hotwire"
)

# Mixed formats
field(:contact).radio(
  [true, "Yes, contact me"],
  [false, "No thanks"],
  "Maybe later"
)

# ActiveRecord relation
field(:category_ids).checkbox(Category.all)

Component API

Checkbox Component

# Constructor accepts options as positional args
Checkbox.new(field, *option_list, attributes: {})

# Public methods
def options(*option_list)  # Renders all options
def option(value, &block)  # Renders single option with label

Radio Component

# Constructor accepts options as positional args
Radio.new(field, *option_list, attributes: {})

# Public methods
def options(*option_list)  # Renders all options
def option(value, &block)  # Renders single option with label

Implementation Details

How Array Mode is Determined

Checkbox:

  • Array mode if options provided OR block given
  • Boolean mode otherwise (backward compatible)
def view_template(&block)
  if array_mode? || block_given?
    # Array mode: multiple checkboxes
  else
    # Boolean mode: single true/false checkbox
  end
end

Radio:

  • Always renders provided options (no boolean mode)
  • Supports block rendering for custom markup

Checked State Logic

Checkbox (multi-select):

def checked_in_collection?(value)
  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

Radio (single-select):

def checked?(value)
  field.value.to_s == value.to_s
end

HTML Output

Checkbox array:

<!-- Uses array notation for multi-select -->
<label>
  <input type="checkbox" name="role_ids[]" value="1">
  Admin
</label>

Radio group:

<!-- Shares same name for mutually exclusive selection -->
<label>
  <input type="radio" name="plan" value="free">
  Free Plan
</label>

Why This Design?

Positional Arguments

Options are inherently a list of choices. Passing them as positional arguments (*args) is more natural than a keyword argument:

# Intuitive - reads like a list
field(:plan).radio(["free", "Free"], ["pro", "Pro"])

# vs. more verbose
field(:plan).radio(options: [["free", "Free"], ["pro", "Pro"]])

Block-Only Support

Enables clean custom rendering without redundant constructor arguments:

# Clean - define options inline with custom markup
field(:gender).radio do |r|
  div { r.option("m") { "Male" } }
  div { r.option("f") { "Female" } }
end

# vs. having to repeat options
field(:gender).radio(["m", "Male"], ["f", "Female"]) do |r|
  div { r.option("m") { "Male" } }  # Redundant!
end

Consistent Naming

Using option aligns with Select component terminology and HTML <option> elements:

# Consistent across components
field(:category).select { |s| s.option(...) }
field(:tags).checkbox { |c| c.option(...) }
field(:plan).radio { |r| r.option(...) }

Copy link
Contributor

@bradgessler bradgessler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is heading in a good direction. There's a few things I noted in the source that get those closer towards me merging it into main. Most of the issues are straight forward; however the one that might need more discussion is how to handle the collection of radio buttons and labels for inputs when not calling via a block.

README.md Outdated
Field(:plan).label { "Choose your plan" }
# Radio group with options - renders multiple radio buttons
Field(:plan).radio(
options: [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The options should look like the select field where they're passed in as positional arguments only. It can accept these arguments:

  1. Field(:color).radio("Red", "Blue", "Green") would create the radio buttons with the labels and the ID that converts the value to an ID, probably red, blue, green`.

  2. Field(:color).radio([1, "Red"], [2, "Blue"], [3, "Green"]) where id is first and the label is second and

  3. Field(:color).radio Color.select(:id, :name)

When people want to work directly with options, they should do so via blocks.

Copy link
Author

@nimmolo nimmolo Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to get rid of the options kwarg path and restrict these to being passed as positional args.

However, I wonder if you recall Select is written to take options as a kwarg, or positional args.

  • Because of the way the constructor is written, options can be sent under the kwarg "collection". This feature is undocumented in the README - maybe nobody uses it, and it's effectively dead code? I'm refactoring checkbox and radio to get rid of the constructor arg.
  • I would propose getting rid of that in select, or at least deprecating it. If we want to keep it, IMO it could be renamed options, because it is an array of values for <option> elements. This use of the term "collection" (in the definition of Select) seems incongruous with its use elsewhere in Superform. (Am i right about that?)


def radio(value, *, **, &)
input(*, **, type: :radio, value: value, &)
def radio(*args, **attributes, &)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eliminating options can simplify this method to something like

if args.any? or if block_given?
  # Call up to Components::Radio
else
  # Use the input tag
end

You don't need to fix this, but I'm realizing in the rest of my code I'm passing blocks into input, which is a void element.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do a separate preliminary PR re: input and blocks while i'm at it.


def buttons(*option_list)
map_options(option_list).each do |value, label|
button(value) { label }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The is invalid HTML, input tags can't have content inside of them because they're void elements. What you can do instead is something like:

<label>Red<input type="radio" value="red" id=""></label>

I think that's fine for when people only pass positional arguments and nothing else, but this approach might be too basic though because when people work with radio buttons in blocks.

They should be able to do something like this:

Field(:color).radio(Color.select(:id, :name)).buttons.each do
  div(class: "flex flex-row"){
    it.label(class: "font-bold bg-red-200")
    div { "Let's put something here just to be annoying" }
    it.button
  end
end

I haven't fully thought through the buttons method above since there's no other fields like it. Maybe a better approach would be something like:

Field(:color).radio_buttons(Color.select(:id, :name)).each do
  div(class: "flex flex-row"){
    it.label(class: "font-bold bg-red-200")
    div { "Let's put something here just to be annoying" }
    it.button
  end
end

Which avoids a conflict with the radio method entirely. This would be true for checkboxes too, which haven't been implemented yet.

Copy link
Author

@nimmolo nimmolo Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as documented now in the README or the PR summary above...

Current API on this branch - you can either use the current single checkbox call field(:foo).checkbox, or you can pass a collection as positional args to field(:foo).checkbox(*opts). To me this is preferable to having a second method like checkbox_buttons. The generated "checkbox-grouping" names and IDs will be correctly generated in the HTML.

Ditto for radio - i don't think we need a radio_buttons method since radios are always displayed as a group of options.

@nimmolo
Copy link
Author

nimmolo commented Nov 18, 2025

For now i'm adding checkbox collection functionality to this PR. That way you can review radio code and the similar method API side-by-side with it. Of course I can extract Checkbox changes to separate PR if you prefer.

It also might make sense to add the multiple Select code changes on this PR too, to be able to think about all three APIs side by side. I've tried to make them intuitive and similar.

In collection mode, the `button` method explicitly sets all input
attributes (type, id, name, value, checked), making the `name` attribute
from `field_attributes` redundant.

Changed `field_attributes` to return `{}` in collection mode since the
`button` method handles all attributes explicitly.

Updated test expectations to match new attribute order (functionally
identical HTML, just different order: type, id, name, value, checked).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@nimmolo nimmolo changed the title add Radio component Checkbox - allow collections, add Radio component Nov 19, 2025
@nimmolo nimmolo marked this pull request as draft November 19, 2025 21:07
@nimmolo nimmolo marked this pull request as ready for review November 20, 2025 18:25
@nimmolo
Copy link
Author

nimmolo commented Nov 21, 2025

To sum up recent changes on this PR:

  • As requested, an array of values/labels representing a single field's value(s) can only be passed as positional args, not via options keyword - for both checkbox and radio
  • The radio method does require passing that array of values/labels, because a single radio input makes no sense in HTML. Radio buttons are a UI for choosing between mutually exclusive values for a single field.
  • By contrast to radio, a single checkbox can represent a Boolean value for a field, or a group of checkboxes with the same name[] attribute can represent an array of values. In the case of a group, each checkbox choice should get a wrapping label
    • The current checkbox API for rendering that single checkbox input still works with no breaking changes. As currently, calling a single checkbox for a field does not render a label element.
    • The checkbox method can detect if the caller has passed an array of values/labels for the same field, so it seems to me a separate checkbox_group method is not required. I feel this API is preferable, but i'm open to feedback
  • Both radio and multiple-choice checkbox methods have been updated and tested to render valid HTML, with a void input element and a wrapping label for each input with the text describing the "option"
  • In the case of multiple choices representing a single field, you often want a label describing "the field overall". (See README for example.) To facilitate this markup, I expanded the label method's API for rendering that "field-wide" label element with label(for: false), a non-breaking change that's necessary to generate valid HTML. (Any label's for attribute must refer to a specific input element.)
  • Checkbox and Radio components' button methods renamed option to harmonize with select API

My goal has been to keep the APIs for both of these methods, plus select, as similar and intuitive as possible. All aspects of the updated API are described more fully in the updated PR summary above, and in the README docs on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants