diff --git a/audit/component_map/toggle_group.yml b/audit/component_map/toggle_group.yml new file mode 100644 index 000000000..57e25cf2b --- /dev/null +++ b/audit/component_map/toggle_group.yml @@ -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 + 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] diff --git a/audit/divergences/toggle_group.yml b/audit/divergences/toggle_group.yml new file mode 100644 index 000000000..fc951ee56 --- /dev/null +++ b/audit/divergences/toggle_group.yml @@ -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" diff --git a/audit/fixtures/toggle_group/content.yml b/audit/fixtures/toggle_group/content.yml new file mode 100644 index 000000000..667e2e021 --- /dev/null +++ b/audit/fixtures/toggle_group/content.yml @@ -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] diff --git a/audit/fixtures/toggle_group/rubyui.rb b/audit/fixtures/toggle_group/rubyui.rb new file mode 100644 index 000000000..850557b8e --- /dev/null +++ b/audit/fixtures/toggle_group/rubyui.rb @@ -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 diff --git a/audit/fixtures/toggle_group/shadcn.tsx b/audit/fixtures/toggle_group/shadcn.tsx new file mode 100644 index 000000000..5708ce851 --- /dev/null +++ b/audit/fixtures/toggle_group/shadcn.tsx @@ -0,0 +1,27 @@ +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" + +export default function ToggleGroupFixture() { + return ( +
+ + Left + Center + Right + + + Bold + Italic + Underline + + + Small + Medium + Large + + + Day + Week + +
+ ) +} diff --git a/docs/app/views/docs/toggle_group.rb b/docs/app/views/docs/toggle_group.rb index 77f15edfa..cccd8aa15 100644 --- a/docs/app/views/docs/toggle_group.rb +++ b/docs/app/views/docs/toggle_group.rb @@ -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" } @@ -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 diff --git a/gem/lib/ruby_ui/toggle/toggle.rb b/gem/lib/ruby_ui/toggle/toggle.rb index accb0677a..b3d5436e5 100644 --- a/gem/lib/ruby_ui/toggle/toggle.rb +++ b/gem/lib/ruby_ui/toggle/toggle.rb @@ -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", diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group.rb b/gem/lib/ruby_ui/toggle_group/toggle_group.rb index e2c6f574a..9fd97e45d 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group.rb @@ -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 @@ -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 super(**attrs) end @@ -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] @@ -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 [ diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js b/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js index 00121ec0b..149165f21 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js @@ -3,95 +3,130 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--toggle-group" export default class extends Controller { static targets = ["item", "input"] - static values = { type: String, name: String } + static values = { + type: String, + name: String, + orientation: { type: String, default: "horizontal" }, + loopFocus: { type: Boolean, default: true } + } connect() { this.reconcile() + // Keep one tab stop when an item gets disabled or enabled later. + this.observer = new MutationObserver(() => this.ensureTabStop()) + this.observer.observe(this.element, { subtree: true, attributes: true, attributeFilter: ["disabled"] }) + } + + disconnect() { + this.observer?.disconnect() + this.observer = null + } + + // Items added or removed after connect: keep one tab stop and the hidden + // inputs in sync with what is pressed. + itemTargetConnected() { + this.ensureTabStop() + this.rebuildInputs() } + itemTargetDisconnected() { + this.ensureTabStop() + this.rebuildInputs() + } + + // Native