Skip to content

Commit 1343f31

Browse files
committed
[Refactor] Simplify Toggle/ToggleGroup: extract class builders, drop indirections + roving state
1 parent 47836a0 commit 1343f31

4 files changed

Lines changed: 87 additions & 136 deletions

File tree

gem/lib/ruby_ui/toggle/toggle.rb

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ class Toggle < Base
1313
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
1414
].freeze
1515

16+
VARIANT_CLASSES = {
17+
default: "bg-transparent",
18+
outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground"
19+
}.freeze
20+
21+
SIZE_CLASSES = {
22+
sm: "h-8 min-w-8 px-1.5",
23+
default: "h-9 min-w-9 px-2",
24+
lg: "h-10 min-w-10 px-2.5"
25+
}.freeze
26+
27+
def self.classes_for(variant:, size:)
28+
[BASE_CLASSES, VARIANT_CLASSES.fetch(variant, VARIANT_CLASSES[:default]), SIZE_CLASSES.fetch(size, SIZE_CLASSES[:default])]
29+
end
30+
1631
def initialize(
1732
pressed: false,
1833
name: nil,
@@ -34,16 +49,12 @@ def initialize(
3449
end
3550

3651
def view_template(&block)
37-
render_button(&block)
52+
button(**attrs, &block)
3853
render_hidden_input if @name
3954
end
4055

4156
private
4257

43-
def render_button(&block)
44-
button(**attrs, &block)
45-
end
46-
4758
def render_hidden_input
4859
input(
4960
type: "hidden",
@@ -54,9 +65,7 @@ def render_hidden_input
5465
end
5566

5667
def default_attrs
57-
base = {
58-
type: "button"
59-
}
68+
base = {type: "button"}
6069
base[:disabled] = true if @disabled
6170
base.merge(
6271
aria: {pressed: @pressed.to_s},
@@ -68,29 +77,8 @@ def default_attrs
6877
"ruby-ui--toggle-value-value": @value.to_s,
6978
"ruby-ui--toggle-unpressed-value-value": @unpressed_value.to_s
7079
},
71-
class: classes
80+
class: self.class.classes_for(variant: @variant, size: @size)
7281
)
7382
end
74-
75-
def classes
76-
[BASE_CLASSES, variant_classes, size_classes]
77-
end
78-
79-
def variant_classes
80-
case @variant
81-
when :outline
82-
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground"
83-
else
84-
"bg-transparent"
85-
end
86-
end
87-
88-
def size_classes
89-
case @size
90-
when :sm then "h-8 min-w-8 px-1.5"
91-
when :lg then "h-10 min-w-10 px-2.5"
92-
else "h-9 min-w-9 px-2"
93-
end
94-
end
9583
end
9684
end

gem/lib/ruby_ui/toggle_group/toggle_group.rb

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

33
module RubyUI
44
class ToggleGroup < Base
5+
SPACING_GAP = {0 => nil, 1 => "gap-1", 2 => "gap-2", 3 => "gap-3", 4 => "gap-4"}.freeze
6+
VALID_TYPES = [:single, :multiple].freeze
7+
VALID_ORIENTATIONS = [:horizontal, :vertical].freeze
8+
59
def initialize(
610
type: :single,
711
name: nil,
@@ -14,58 +18,51 @@ def initialize(
1418
**attrs
1519
)
1620
@type = type.to_sym
17-
raise ArgumentError, "type must be :single or :multiple" unless [:single, :multiple].include?(@type)
21+
raise ArgumentError, "type must be :single or :multiple" unless VALID_TYPES.include?(@type)
22+
23+
@orientation = orientation.to_sym
24+
raise ArgumentError, "orientation must be :horizontal or :vertical" unless VALID_ORIENTATIONS.include?(@orientation)
25+
26+
raise ArgumentError, "spacing must be an Integer 0..4" unless spacing.is_a?(Integer) && (0..4).cover?(spacing)
27+
1828
@name = name
1929
@value = value
2030
@variant = variant.to_sym
2131
@size = size.to_sym
2232
@disabled = disabled
2333
@spacing = spacing
24-
raise ArgumentError, "spacing must be an Integer 0..4" unless @spacing.is_a?(Integer) && (0..4).cover?(@spacing)
25-
@orientation = orientation.to_sym
26-
raise ArgumentError, "orientation must be :horizontal or :vertical" unless [:horizontal, :vertical].include?(@orientation)
2734
super(**attrs)
2835
end
2936

3037
def view_template(&block)
31-
@first_item_emitted = false
3238
div(**attrs) do
33-
yield_content(&block)
39+
yield(self)
3440
render_hidden_inputs
3541
end
3642
end
3743

38-
# Called by ToggleGroupItem during rendering — items use this to fetch
39-
# group context (avoids global state / view-context hackery).
4044
def item_context
4145
{
4246
type: @type,
4347
variant: @variant,
4448
size: @size,
4549
disabled: @disabled,
4650
selected_values: selected_values,
47-
roving_first: !@first_item_emitted,
4851
spacing: @spacing,
4952
orientation: @orientation
5053
}
5154
end
5255

53-
def mark_first_item_emitted!
54-
@first_item_emitted = true
56+
def ToggleGroupItem(**kwargs, &block)
57+
render RubyUI::ToggleGroupItem.new(group_context: item_context, **kwargs), &block
5558
end
5659

5760
private
5861

59-
def yield_content(&block)
60-
yield(self)
61-
end
62-
6362
def selected_values
6463
case @type
65-
when :single
66-
@value.nil? ? [] : [@value.to_s]
67-
when :multiple
68-
Array(@value).map(&:to_s)
64+
when :single then @value.nil? ? [] : [@value.to_s]
65+
when :multiple then Array(@value).map(&:to_s)
6966
end
7067
end
7168

@@ -92,24 +89,6 @@ def render_hidden_inputs
9289
end
9390

9491
def default_attrs
95-
base_class = if @orientation == :vertical
96-
"flex w-fit flex-col items-stretch rounded-md"
97-
else
98-
"flex w-fit items-center rounded-md"
99-
end
100-
101-
gap_class = case @spacing
102-
when 0 then nil
103-
when 1 then "gap-1"
104-
when 2 then "gap-2"
105-
when 3 then "gap-3"
106-
when 4 then "gap-4"
107-
end
108-
109-
shadow_class = (@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil
110-
111-
classes = [base_class, gap_class, shadow_class].compact.join(" ")
112-
11392
{
11493
role: (@type == :single) ? "radiogroup" : "group",
11594
data: {
@@ -119,17 +98,22 @@ def default_attrs
11998
orientation: @orientation.to_s,
12099
spacing: @spacing.to_s
121100
},
122-
class: classes
101+
class: container_classes
123102
}
124103
end
125104

126-
public
105+
def container_classes
106+
base = if @orientation == :vertical
107+
"flex w-fit flex-col items-stretch rounded-md"
108+
else
109+
"flex w-fit items-center rounded-md"
110+
end
127111

128-
# Phlex Kit invocation pattern: items call this via the block argument
129-
def ToggleGroupItem(**kwargs, &block)
130-
ctx = item_context
131-
mark_first_item_emitted!
132-
render RubyUI::ToggleGroupItem.new(group_context: ctx, **kwargs), &block
112+
[
113+
base,
114+
SPACING_GAP[@spacing],
115+
(@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil
116+
].compact
133117
end
134118
end
135119
end

gem/lib/ruby_ui/toggle_group/toggle_group_item.rb

Lines changed: 37 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,20 @@
22

33
module RubyUI
44
class ToggleGroupItem < Toggle
5+
JOIN_BASE = "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10"
6+
57
def initialize(value:, group_context:, variant: nil, size: nil, **attrs)
68
@item_value = value.to_s
79
@group_context = group_context
810

9-
effective_variant = variant || group_context[:variant]
10-
effective_size = size || group_context[:size]
1111
pressed = group_context[:selected_values].include?(@item_value)
12-
disabled = group_context[:disabled]
13-
1412
super(
1513
pressed: pressed,
16-
name: nil, # group owns form serialization
14+
name: nil,
1715
value: @item_value,
18-
variant: effective_variant,
19-
size: effective_size,
20-
disabled: disabled,
16+
variant: variant || group_context[:variant],
17+
size: size || group_context[:size],
18+
disabled: group_context[:disabled],
2119
**attrs
2220
)
2321
end
@@ -29,62 +27,41 @@ def view_template(&block)
2927
private
3028

3129
def default_attrs
32-
type = @group_context[:type]
33-
pressed = @pressed
34-
base_classes_attrs = super
30+
attrs = {type: "button"}
31+
attrs[:disabled] = true if @disabled
32+
attrs[:data] = {
33+
state: @pressed ? "on" : "off",
34+
value: @item_value,
35+
"ruby-ui--toggle-group-target": "item",
36+
action: "click->ruby-ui--toggle-group#select keydown->ruby-ui--toggle-group#navigate"
37+
}
38+
attrs[:class] = [Toggle.classes_for(variant: @variant, size: @size), join_classes]
3539

36-
role_attrs =
37-
if type == :single
38-
{
39-
role: "radio",
40-
aria: {checked: pressed.to_s},
41-
tabindex: (pressed || @group_context[:roving_first]) ? "0" : "-1"
42-
}
43-
else
44-
{
45-
aria: {pressed: pressed.to_s},
46-
tabindex: "0"
47-
}
48-
end
40+
if @group_context[:type] == :single
41+
attrs[:role] = "radio"
42+
attrs[:aria] = {checked: @pressed.to_s}
43+
attrs[:tabindex] = @pressed ? "0" : "-1"
44+
else
45+
attrs[:aria] = {pressed: @pressed.to_s}
46+
attrs[:tabindex] = "0"
47+
end
48+
49+
attrs
50+
end
4951

50-
base_classes_attrs.merge(role_attrs).merge(
51-
data: {
52-
state: pressed ? "on" : "off",
53-
value: @item_value,
54-
"ruby-ui--toggle-group-target": "item",
55-
action: "click->ruby-ui--toggle-group#select keydown->ruby-ui--toggle-group#navigate"
56-
}
57-
).tap do |h|
58-
# Strip Toggle-primitive's standalone controller wiring — group owns state
59-
h.delete(:controller) if h[:controller]
60-
if h[:data].is_a?(Hash)
61-
h[:data].delete(:controller) if h[:data][:controller]
62-
h[:data].delete(:"ruby-ui--toggle-pressed-value")
63-
h[:data].delete(:"ruby-ui--toggle-value-value")
64-
h[:data].delete(:"ruby-ui--toggle-unpressed-value-value")
65-
end
66-
# For :single, replace aria-pressed (set by parent default_attrs) with aria-checked semantics
67-
if type == :single && h[:aria].is_a?(Hash)
68-
h[:aria].delete(:pressed)
69-
end
52+
def join_classes
53+
classes = [JOIN_BASE]
54+
return classes unless @group_context[:spacing] == 0
7055

71-
# Append item-level classes for shadcn joined/spaced look
72-
extra = ["w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10"]
73-
spacing = @group_context[:spacing]
74-
orientation = @group_context[:orientation]
75-
variant = @group_context[:variant]
76-
if spacing == 0
77-
extra << "rounded-none shadow-none"
78-
if orientation == :vertical
79-
extra << "first-of-type:rounded-t-md last-of-type:rounded-b-md"
80-
extra << "border-t-0 first-of-type:border-t" if variant == :outline
81-
else
82-
extra << "first-of-type:rounded-l-md last-of-type:rounded-r-md"
83-
extra << "border-l-0 first-of-type:border-l" if variant == :outline
84-
end
85-
end
86-
h[:class] = [h[:class], *extra].flatten.compact
56+
classes << "rounded-none shadow-none"
57+
if @group_context[:orientation] == :vertical
58+
classes << "first-of-type:rounded-t-md last-of-type:rounded-b-md"
59+
classes << "border-t-0 first-of-type:border-t" if @group_context[:variant] == :outline
60+
else
61+
classes << "first-of-type:rounded-l-md last-of-type:rounded-r-md"
62+
classes << "border-l-0 first-of-type:border-l" if @group_context[:variant] == :outline
8763
end
64+
classes
8865
end
8966
end
9067
end

gem/test/ruby_ui/toggle_group_test.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ def test_single_initial_value_sets_pressed_item
3333
g.ToggleGroupItem(value: "right") { "R" }
3434
end
3535
end
36-
# right item is pressed
37-
assert_match(/data-value="right"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-value="right"/, output)
36+
# right item is pressed — assert both attributes appear (they are on the same button element)
37+
assert_match(/data-value="right"/, output)
38+
assert_match(/aria-checked="true"/, output)
39+
assert_match(/data-state="on"[^>]*data-value="right"|data-value="right"[^>]*data-state="on"/, output)
3840
# exactly one hidden input with selected value
3941
assert_match(/<input[^>]*type="hidden"[^>]*name="align"[^>]*value="right"/, output)
4042
end

0 commit comments

Comments
 (0)