Rating Group
Star rating input with keyboard navigation, half-star support, and read-only mode.
Installation
bin/rails g wabi:add rating_groupExample
render Components::UI::RatingGroup.new(name: "score", value: 3)
Half stars & read-only
render Components::UI::RatingGroup.new(name: "rating", value: 2.5, allow_half: true, read_only: true)
Source
app/components/ui/rating_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 RatingGroup < Wabi::Base
def initialize(name: nil, value: 0, count: 5, allow_half: false, read_only: false, disabled: false, **attrs)
# value: 0 (or any <= 0) means "unrated" — Zag treats it as empty; the JS controller uses -1 as its sentinel.
@name = name
@value = value
@count = count
@allow_half = allow_half
@read_only = read_only
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
**@attrs,
data: {
controller: "wabi--rating-group",
"wabi--rating-group-name-value": @name,
"wabi--rating-group-value-value": @value.to_s,
"wabi--rating-group-count-value": @count.to_s,
"wabi--rating-group-allow-half-value": @allow_half.to_s,
"wabi--rating-group-read-only-value": @read_only.to_s,
"wabi--rating-group-disabled-value": @disabled.to_s,
},
class: merge_class("flex flex-col gap-2", user_class)
) do
if block
yield
else
render RatingGroupControl.new(count: @count)
end
input(type: "hidden", name: @name, value: @value.to_s, data: { "wabi--rating-group-target": "hiddenInput" })
end
end
end
end
end
app/components/ui/rating_group_label.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class RatingGroupLabel < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
label(
data: { "wabi--rating-group-target": "label" },
class: merge_class("text-sm font-medium leading-none", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/rating_group_control.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
# Thin wrapper around the radiogroup container for manual composition.
# When `count:` is given (default rendering), renders that many RatingGroupItem spans.
# When a block is given instead, yields to it (manual composition).
class RatingGroupControl < Wabi::Base
def initialize(count: nil, **attrs)
@count = count
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
**@attrs,
role: "radiogroup",
data: { "wabi--rating-group-target": "control" },
class: merge_class("flex items-center gap-1", user_class)
) do
if block
yield
elsif @count
@count.times do |i|
render RatingGroupItem.new(index: i + 1)
end
end
end
end
end
end
end
app/components/ui/rating_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 RatingGroupItem < Wabi::Base
STAR_PATH = "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
OUTLINE_SVG = %(<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute inset-0 h-full w-full"><path d="#{STAR_PATH}"/></svg>)
FILLED_SVG = %(<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="absolute inset-0 h-full w-full text-yellow-400 opacity-0 group-data-[highlighted]:opacity-100 group-data-[half]:[clip-path:inset(0_50%_0_0)]"><path d="#{STAR_PATH}"/></svg>)
def initialize(index:, **attrs)
@index = index
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
span(
data: { "wabi--rating-group-target": "item", "wabi-index": @index.to_s },
class: merge_class(
"group relative inline-block h-6 w-6 cursor-pointer text-muted-foreground " \
"transition-colors motion-reduce:transition-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 " \
"data-[readonly]:cursor-default rounded-sm outline-none " \
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
user_class
)
) do
raw(safe(OUTLINE_SVG))
raw(safe(FILLED_SVG))
end
end
end
end
end
Accessibility
- Root renders role="radiogroup"; each star is keyboard-navigable with arrow keys.
- Pass a label via RatingGroupLabel for screen reader context.
- read_only: true removes interaction while keeping the value announced.