Skip to content
Merged
36 changes: 36 additions & 0 deletions audit/component_map/toggle_group.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
shadcn: toggle-group
shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f
kind: form_control
docs_view: toggle_group
parts:
ToggleGroup: toggle-group
ToggleGroupItem: toggle-group-item
selectors:
ToggleGroup: '[data-slot="toggle-group"]'
ToggleGroupItem: '[data-slot="toggle-group-item"]'
props:
# shadcn's own props (variant, size, spacing, orientation) + Base UI ToggleGroup.Props.
ToggleGroup:
variant: variant
size: size
spacing: spacing
orientation: orientation
multiple: type
# Base UI defaultValue is always an array; RubyUI value is a scalar for type: :single
# and an array for :multiple.
defaultValue: value
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
value: null
onValueChange: null
disabled: disabled
loopFocus: loop_focus
# shadcn's variant/size + Base UI Toggle.Props.
ToggleGroupItem:
variant: variant
size: size
value: value
disabled: disabled # item kwarg reaches Toggle and overrides the group value
pressed: null
defaultPressed: null
onPressedChange: null
rubyui_only:
ToggleGroup: [name]
12 changes: 12 additions & 0 deletions audit/divergences/toggle_group.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
- component: toggle_group
dimension: Behavior
rubyui: "With a selection, Tab enters the group on the first pressed item"
shadcn: "Base UI makes the first item the tab stop whatever is pressed"
reason: "Landing on the current choice tells keyboard users the group's value at once and saves arrow presses; with no selection both enter on the first item."
since: "2026-11-04"
- component: toggle_group
dimension: Props
rubyui: "value sets the initial state; the state reaches forms through hidden inputs (name)"
shadcn: "value/onValueChange, and pressed/defaultPressed/onPressedChange on items (controlled React state)"
reason: "Server-rendered Phlex has no controlled state or JS callbacks; listen to clicks on the items or read the hidden inputs."
since: "2026-11-04"
12 changes: 12 additions & 0 deletions audit/fixtures/toggle_group/content.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Four groups, same strings and options on both sides:
# - Alignment: single, outline, spacing 0 (joined), nothing pressed. data-audit-trigger
# sits on Left: with no selection both libraries make the first item the tab stop
# (with a selection RubyUI picks the pressed item, Base UI the first one).
# - Formatting: multiple, default variant, spacing 1, Bold pressed.
# - Size: single, vertical, outline, size sm, spacing 0, Medium pressed.
# - Range: multiple, outline, spacing 2, whole group disabled.
groups: [Alignment, Formatting, Size, Range]
alignment: [Left, Center, Right]
formatting: [Bold, Italic, Underline]
size: [Small, Medium, Large]
range: [Day, Week]
32 changes: 32 additions & 0 deletions audit/fixtures/toggle_group/rubyui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module ShadcnAuditFixtures
# RubyUI side of audit/fixtures/toggle_group (strings in content.yml).
class ToggleGroup < Phlex::HTML
include RubyUI

def view_template
div(id: "toggle-group-fixture", class: "flex flex-col items-start gap-6") do
ToggleGroup(type: :single, name: "alignment", variant: :outline, spacing: 0, aria: {label: "Alignment"}) do |g|
g.ToggleGroupItem(value: "left", data: {audit_trigger: true}) { "Left" }
g.ToggleGroupItem(value: "center") { "Center" }
g.ToggleGroupItem(value: "right") { "Right" }
end
ToggleGroup(type: :multiple, name: "formatting", value: %w[bold], spacing: 1, aria: {label: "Formatting"}) do |g|
g.ToggleGroupItem(value: "bold") { "Bold" }
g.ToggleGroupItem(value: "italic") { "Italic" }
g.ToggleGroupItem(value: "underline") { "Underline" }
end
ToggleGroup(type: :single, name: "size", value: "medium", variant: :outline, size: :sm, spacing: 0, orientation: :vertical, aria: {label: "Size"}) do |g|
g.ToggleGroupItem(value: "small") { "Small" }
g.ToggleGroupItem(value: "medium") { "Medium" }
g.ToggleGroupItem(value: "large") { "Large" }
end
ToggleGroup(type: :multiple, name: "range", variant: :outline, spacing: 2, disabled: true, aria: {label: "Range"}) do |g|
g.ToggleGroupItem(value: "day") { "Day" }
g.ToggleGroupItem(value: "week") { "Week" }
end
end
end
end
end
27 changes: 27 additions & 0 deletions audit/fixtures/toggle_group/shadcn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export default function ToggleGroupFixture() {
return (
<div id="toggle-group-fixture" className="flex flex-col items-start gap-6">
<ToggleGroup aria-label="Alignment" variant="outline" spacing={0}>
<ToggleGroupItem value="left" data-audit-trigger="">Left</ToggleGroupItem>
<ToggleGroupItem value="center">Center</ToggleGroupItem>
<ToggleGroupItem value="right">Right</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup aria-label="Formatting" multiple spacing={1} defaultValue={["bold"]}>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
<ToggleGroupItem value="underline">Underline</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup aria-label="Size" variant="outline" size="sm" spacing={0} orientation="vertical" defaultValue={["medium"]}>
<ToggleGroupItem value="small">Small</ToggleGroupItem>
<ToggleGroupItem value="medium">Medium</ToggleGroupItem>
<ToggleGroupItem value="large">Large</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup aria-label="Range" multiple variant="outline" spacing={2} disabled>
<ToggleGroupItem value="day">Day</ToggleGroupItem>
<ToggleGroupItem value="week">Week</ToggleGroupItem>
</ToggleGroup>
</div>
)
}
22 changes: 16 additions & 6 deletions docs/app/views/docs/toggle_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def view_template
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(
title: "Toggle Group",
description: "A set of two-state buttons that can be toggled on or off, with single or multiple selection. Mirrors shadcn/ui ToggleGroup."
description: "A set of two-state buttons that can be toggled on or off, with single or multiple selection. Arrow keys move between items (loop_focus: false stops at the ends); pressing the pressed item of a single group clears it. Mirrors shadcn/ui ToggleGroup."
)

Heading(level: 2) { "Usage" }
Expand Down Expand Up @@ -75,11 +75,21 @@ def view_template

render Docs::VisualCodeExample.new(title: "Spacing", context: self) do
<<~RUBY
ToggleGroup(type: :single, name: "align", value: "top", variant: :outline, spacing: 2) do |g|
g.ToggleGroupItem(value: "top") { "Top" }
g.ToggleGroupItem(value: "bottom") { "Bottom" }
g.ToggleGroupItem(value: "left") { "Left" }
g.ToggleGroupItem(value: "right") { "Right" }
div(class: "flex flex-col gap-4") do
# spacing: 0 joins the items into one segmented control
ToggleGroup(type: :single, name: "align_joined", value: "top", variant: :outline, spacing: 0) do |g|
g.ToggleGroupItem(value: "top") { "Top" }
g.ToggleGroupItem(value: "bottom") { "Bottom" }
g.ToggleGroupItem(value: "left") { "Left" }
g.ToggleGroupItem(value: "right") { "Right" }
end
# spacing defaults to 2 (gap-2); any Integer 0..4 works
ToggleGroup(type: :single, name: "align", value: "top", variant: :outline) do |g|
g.ToggleGroupItem(value: "top") { "Top" }
g.ToggleGroupItem(value: "bottom") { "Bottom" }
g.ToggleGroupItem(value: "left") { "Left" }
g.ToggleGroupItem(value: "right") { "Right" }
end
end
RUBY
end
Expand Down
4 changes: 0 additions & 4 deletions gem/lib/ruby_ui/toggle/toggle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ class Toggle < Base
"group/toggle inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap",
"transition-[color,box-shadow] outline-none",
"hover:bg-muted hover:text-foreground aria-pressed:bg-muted",
# ToggleGroupItem in single mode is role=radio + aria-checked with data-state,
# so aria-pressed misses it. Kept here so an app that updates only Toggle
# keeps its existing ToggleGroup's pressed style.
"data-[state=on]:bg-muted",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
Expand Down
46 changes: 33 additions & 13 deletions gem/lib/ruby_ui/toggle_group/toggle_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ def initialize(
variant: :default,
size: :default,
disabled: false,
spacing: 0,
spacing: 2,
orientation: :horizontal,
loop_focus: true,
**attrs
)
@type = type.to_sym
Expand All @@ -31,6 +32,9 @@ def initialize(
@size = size.to_sym
@disabled = disabled
@spacing = spacing
raise ArgumentError, "loop_focus must be true or false" unless [true, false].include?(loop_focus)

@loop_focus = loop_focus
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
super(**attrs)
end

Expand All @@ -54,11 +58,22 @@ def item_context
end

def ToggleGroupItem(**kwargs, &block)
render RubyUI::ToggleGroupItem.new(group_context: item_context, **kwargs), &block
context = item_context.merge(tab_stop: claim_tab_stop?(kwargs))
render RubyUI::ToggleGroupItem.new(group_context: context, **kwargs), &block
end

private

# One tab stop in the server HTML: the first pressed enabled item, else the
# first enabled item. The controller keeps it in sync afterwards.
def claim_tab_stop?(kwargs)
return false if @tab_stop_claimed
return false if kwargs.fetch(:disabled, @disabled)
return false if selected_values.any? && !selected_values.include?(kwargs[:value].to_s)

@tab_stop_claimed = true
end

def selected_values
case @type
when :single then @value.nil? ? [] : [@value.to_s]
Expand Down Expand Up @@ -89,24 +104,29 @@ def render_hidden_inputs
end

def default_attrs
{
role: (@type == :single) ? "radiogroup" : "group",
data: {
controller: "ruby-ui--toggle-group",
"ruby-ui--toggle-group-type-value": @type.to_s,
"ruby-ui--toggle-group-name-value": @name.to_s,
orientation: @orientation.to_s,
spacing: @spacing.to_s
},
class: container_classes
data = {
slot: "toggle-group",
variant: @variant.to_s,
size: @size.to_s,
spacing: @spacing.to_s,
orientation: @orientation.to_s,
controller: "ruby-ui--toggle-group",
"ruby-ui--toggle-group-type-value": @type.to_s,
"ruby-ui--toggle-group-name-value": @name.to_s,
"ruby-ui--toggle-group-orientation-value": @orientation.to_s,
"ruby-ui--toggle-group-loop-focus-value": @loop_focus.to_s
}
data[:multiple] = "" if @type == :multiple
data[:disabled] = "" if @disabled

{role: "group", data: data, class: container_classes}
end

def container_classes
base = if @orientation == :vertical
"flex w-fit flex-col items-stretch rounded-md"
else
"flex w-fit items-center rounded-md"
"flex w-fit flex-row items-center rounded-md"
end

[
Expand Down
Loading
Loading