ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

RadioGroup

Group of radio inputs with keyboard navigation.

Installation

bin/rails g wabi:add radio_group
bin/importmap pin @zag-js/radio-group
bin/importmap pin @zag-js/vanilla

Pin @zag-js/radio-group and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.

Example

Source

app/components/ui/radio_group.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class RadioGroup < Wabi::Base
      # +aria_label:+ provides an accessible name for the radiogroup landmark.
      # Callers should supply a concise string describing the group, e.g.
      # <tt>aria_label: "Subscription plan"</tt>. Alternatively pass
      # <tt>aria_labelledby: "some-heading-id"</tt> via **attrs to reference
      # an existing heading; callers must supply at least one of the two.
      def initialize(name: nil, value: nil, disabled: false, aria_label: nil, **attrs)
        @name       = name
        @value      = value
        @disabled   = disabled
        @aria_label = aria_label
        @attrs      = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          **@attrs,
          role: "radiogroup",
          aria: { label: @aria_label },
          data: {
            controller: "wabi--radio-group",
            "wabi--radio-group-name-value":     @name,
            "wabi--radio-group-value-value":    @value,
            "wabi--radio-group-disabled-value": @disabled.to_s,
          },
          class: merge_class("grid gap-2", user_class),
          &block
        )
      end
    end
  end
end

app/components/ui/radio_group_item.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class RadioGroupItem < Wabi::Base
      # w-fit keeps the clickable label sized to its content — without it, as a
      # flex/grid item the <label> stretches full-width and the empty space beside
      # the text becomes a click target that selects the radio.
      ITEM_CLASS = "inline-flex w-fit items-center gap-2 cursor-pointer " \
                   "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"

      CONTROL_CLASS = "aspect-square h-4 w-4 rounded-full border border-primary " \
                      "text-primary shadow ring-offset-background " \
                      "focus-visible:outline-none focus-visible:ring-2 " \
                      "focus-visible:ring-ring focus-visible:ring-offset-2 " \
                      "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 " \
                      "flex items-center justify-center"

      def initialize(value:, disabled: false, **attrs)
        @value    = value
        @disabled = disabled
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        label(
          data: {
            "wabi--radio-group-target": "item",
            "wabi-value": @value,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(ITEM_CLASS, user_class)
        ) do
          # Hidden input for form submission (Zag fills in checked + name via getHiddenInputProps).
          input(
            type: "radio",
            value: @value,
            data: { "wabi--radio-group-target": "hiddenInput", "wabi-value": @value },
            class: "sr-only"
          )
          # The control (radio dot container)
          span(
            data: { "wabi--radio-group-target": "itemControl", "wabi-value": @value },
            class: CONTROL_CLASS
          ) do
            render Components::UI::RadioGroupIndicator.new
          end
          # The text label
          span(data: { "wabi--radio-group-target": "itemText", "wabi-value": @value }) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/components/ui/radio_group_indicator.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class RadioGroupIndicator < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        span(
          data: { "wabi--radio-group-target": "itemIndicator" },
          "data-state": "unchecked",
          class: merge_class(
            "h-2.5 w-2.5 rounded-full bg-primary " \
            "data-[state=checked]:opacity-100 data-[state=unchecked]:opacity-0 " \
            "transition-opacity motion-reduce:transition-none",
            user_class
          )
        )
      end
    end
  end
end

Accessibility

  • role="radiogroup" wrapper + role="radio" per item.
  • Arrow keys move focus + selection. Space selects the focused item.
  • When inside a <label>, clicking the label toggles the radio.
  • Hidden <input type="radio"> per item for form submission.