From 0209f54875df28aceddc4689033052f2ad7e0fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 18:11:54 -0300 Subject: [PATCH 01/22] [Feature] Add Toggle component (button + aria-pressed + hidden input) --- gem/lib/ruby_ui/toggle/toggle.rb | 96 ++++++++++++++++++++++++++++++++ gem/test/ruby_ui/toggle_test.rb | 68 ++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 gem/lib/ruby_ui/toggle/toggle.rb create mode 100644 gem/test/ruby_ui/toggle_test.rb diff --git a/gem/lib/ruby_ui/toggle/toggle.rb b/gem/lib/ruby_ui/toggle/toggle.rb new file mode 100644 index 00000000..52c418c7 --- /dev/null +++ b/gem/lib/ruby_ui/toggle/toggle.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module RubyUI + class Toggle < Base + BASE_CLASSES = [ + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap", + "transition-[color,box-shadow] outline-none", + "hover:bg-muted hover:text-muted-foreground", + "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", + "data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + ].freeze + + def initialize( + pressed: false, + name: nil, + value: "1", + unpressed_value: nil, + variant: :default, + size: :default, + disabled: false, + **attrs + ) + @pressed = pressed + @name = name + @value = value + @unpressed_value = unpressed_value + @variant = variant.to_sym + @size = size.to_sym + @disabled = disabled + super(**attrs) + end + + def view_template(&block) + render_button(&block) + render_hidden_input if @name + end + + private + + def render_button(&block) + button(**attrs, &block) + end + + def render_hidden_input + input( + type: "hidden", + name: @name, + value: @pressed ? @value : @unpressed_value.to_s, + data: {"ruby-ui--toggle-target": "input"} + ) + end + + def default_attrs + base = { + type: "button" + } + base[:disabled] = true if @disabled + base.merge( + aria: {pressed: @pressed.to_s}, + data: { + state: @pressed ? "on" : "off", + controller: "ruby-ui--toggle", + action: "click->ruby-ui--toggle#toggle", + "ruby-ui--toggle-pressed-value": @pressed.to_s, + "ruby-ui--toggle-value-value": @value.to_s, + "ruby-ui--toggle-unpressed-value-value": @unpressed_value.to_s + }, + class: classes + ) + end + + def classes + [BASE_CLASSES, variant_classes, size_classes] + end + + def variant_classes + case @variant + when :outline + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground" + else + "bg-transparent" + end + end + + def size_classes + case @size + when :sm then "h-8 min-w-8 px-1.5" + when :lg then "h-10 min-w-10 px-2.5" + else "h-9 min-w-9 px-2" + end + end + end +end diff --git a/gem/test/ruby_ui/toggle_test.rb b/gem/test/ruby_ui/toggle_test.rb new file mode 100644 index 00000000..4c278a74 --- /dev/null +++ b/gem/test/ruby_ui/toggle_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::ToggleTest < ComponentTest + def test_renders_button_unpressed_by_default + output = phlex { RubyUI.Toggle { "Bold" } } + assert_match(/]*type="button"/, output) + assert_match(/aria-pressed="false"/, output) + assert_match(/data-state="off"/, output) + assert_match(/Bold/, output) + end + + def test_renders_pressed_when_pressed_true + output = phlex { RubyUI.Toggle(pressed: true) { "Bold" } } + assert_match(/aria-pressed="true"/, output) + assert_match(/data-state="on"/, output) + end + + def test_renders_hidden_input_when_name_present + output = phlex { RubyUI.Toggle(name: "bold", value: "1") { "Bold" } } + assert_match(/]*type="hidden"[^>]*name="bold"/, output) + assert_match(/value=""/, output) + end + + def test_hidden_input_value_reflects_pressed + output = phlex { RubyUI.Toggle(name: "bold", value: "1", pressed: true) { "Bold" } } + assert_match(/]*name="bold"[^>]*value="1"/, output) + end + + def test_no_hidden_input_when_name_absent + output = phlex { RubyUI.Toggle { "Bold" } } + refute_match(/type="hidden"/, output) + end + + def test_outline_variant_applies_border_class + output = phlex { RubyUI.Toggle(variant: :outline) { "x" } } + assert_match(/border-input/, output) + end + + def test_size_sm_applies_h8 + output = phlex { RubyUI.Toggle(size: :sm) { "x" } } + assert_match(/h-8/, output) + end + + def test_size_lg_applies_h10 + output = phlex { RubyUI.Toggle(size: :lg) { "x" } } + assert_match(/h-10/, output) + end + + def test_disabled_sets_attribute + output = phlex { RubyUI.Toggle(disabled: true) { "x" } } + assert_match(/]*disabled/, output) + end + + def test_includes_stimulus_controller_and_action + output = phlex { RubyUI.Toggle { "x" } } + assert_match(/data-controller="[^"]*ruby-ui--toggle/, output) + assert_match(/data-action="[^"]*click->ruby-ui--toggle#toggle/, output) + end + + def test_includes_stimulus_values + output = phlex { RubyUI.Toggle(value: "x", unpressed_value: "y", pressed: true) { "x" } } + assert_match(/data-ruby-ui--toggle-pressed-value="true"/, output) + assert_match(/data-ruby-ui--toggle-value-value="x"/, output) + assert_match(/data-ruby-ui--toggle-unpressed-value-value="y"/, output) + end +end From 51c89e3e2e6c8e972f1ef74c5523988c33e92c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 18:14:48 -0300 Subject: [PATCH 02/22] [Feature] Toggle Stimulus controller --- gem/lib/ruby_ui/toggle/toggle_controller.js | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 gem/lib/ruby_ui/toggle/toggle_controller.js diff --git a/gem/lib/ruby_ui/toggle/toggle_controller.js b/gem/lib/ruby_ui/toggle/toggle_controller.js new file mode 100644 index 00000000..0cacd437 --- /dev/null +++ b/gem/lib/ruby_ui/toggle/toggle_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="ruby-ui--toggle" +export default class extends Controller { + static targets = ["input"] + static values = { + pressed: Boolean, + value: String, + unpressedValue: String + } + + toggle(event) { + if (this.element.disabled) return + this.pressedValue = !this.pressedValue + } + + pressedValueChanged(current) { + this.element.setAttribute("aria-pressed", current ? "true" : "false") + this.element.dataset.state = current ? "on" : "off" + + if (this.hasInputTarget) { + this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue + } + + this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + } +} From dc83bffbe6c8c05a24a788b047a135ad67de56c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 18:15:19 -0300 Subject: [PATCH 03/22] [Feature] Register toggle/toggle_group/theme_toggle component dependencies --- gem/lib/generators/ruby_ui/dependencies.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml index cea551a1..092f476b 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -101,3 +101,13 @@ tooltip: js_packages: - "@floating-ui/dom" + +toggle: {} + +toggle_group: + components: + - "Toggle" + +theme_toggle: + components: + - "Toggle" From 23d5a16e951b6dd011fec3d1f869a199ec36ae0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 18:16:50 -0300 Subject: [PATCH 04/22] [Feature] Add ToggleGroup + ToggleGroupItem (single/multiple, ARIA, form binding) --- gem/lib/ruby_ui/toggle_group/toggle_group.rb | 107 ++++++++++++++++++ .../ruby_ui/toggle_group/toggle_group_item.rb | 73 ++++++++++++ gem/test/ruby_ui/toggle_group_test.rb | 96 ++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 gem/lib/ruby_ui/toggle_group/toggle_group.rb create mode 100644 gem/lib/ruby_ui/toggle_group/toggle_group_item.rb create mode 100644 gem/test/ruby_ui/toggle_group_test.rb diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group.rb b/gem/lib/ruby_ui/toggle_group/toggle_group.rb new file mode 100644 index 00000000..e8b97d93 --- /dev/null +++ b/gem/lib/ruby_ui/toggle_group/toggle_group.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module RubyUI + class ToggleGroup < Base + def initialize( + type: :single, + name: nil, + value: nil, + variant: :default, + size: :default, + disabled: false, + **attrs + ) + @type = type.to_sym + raise ArgumentError, "type must be :single or :multiple" unless [:single, :multiple].include?(@type) + @name = name + @value = value + @variant = variant.to_sym + @size = size.to_sym + @disabled = disabled + super(**attrs) + end + + def view_template(&block) + @first_item_emitted = false + div(**attrs) do + yield_content(&block) + render_hidden_inputs + end + end + + # Called by ToggleGroupItem during rendering — items use this to fetch + # group context (avoids global state / view-context hackery). + def item_context + { + type: @type, + variant: @variant, + size: @size, + disabled: @disabled, + selected_values: selected_values, + roving_first: !@first_item_emitted + } + end + + def mark_first_item_emitted! + @first_item_emitted = true + end + + private + + def yield_content(&block) + yield(self) + end + + def selected_values + case @type + when :single + @value.nil? ? [] : [@value.to_s] + when :multiple + Array(@value).map(&:to_s) + end + end + + def render_hidden_inputs + return unless @name + + if @type == :single + input( + type: "hidden", + name: @name, + value: selected_values.first.to_s, + data: {"ruby-ui--toggle-group-target": "input"} + ) + else + selected_values.each do |v| + input( + type: "hidden", + name: "#{@name}[]", + value: v, + data: {"ruby-ui--toggle-group-target": "input"} + ) + end + end + 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 + }, + class: "inline-flex items-center justify-center gap-1" + } + end + + public + + # Phlex Kit invocation pattern: items call this via the block argument + def ToggleGroupItem(**kwargs, &block) + ctx = item_context + mark_first_item_emitted! + render RubyUI::ToggleGroupItem.new(group_context: ctx, **kwargs), &block + end + end +end diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb new file mode 100644 index 00000000..3338cd37 --- /dev/null +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module RubyUI + class ToggleGroupItem < Toggle + def initialize(value:, group_context:, variant: nil, size: nil, **attrs) + @item_value = value.to_s + @group_context = group_context + + effective_variant = variant || group_context[:variant] + effective_size = size || group_context[:size] + pressed = group_context[:selected_values].include?(@item_value) + disabled = group_context[:disabled] + + super( + pressed: pressed, + name: nil, # group owns form serialization + value: @item_value, + variant: effective_variant, + size: effective_size, + disabled: disabled, + **attrs + ) + end + + def view_template(&block) + button(**attrs, &block) + end + + private + + def default_attrs + type = @group_context[:type] + pressed = @pressed + base_classes_attrs = super + + role_attrs = + if type == :single + { + role: "radio", + aria: {checked: pressed.to_s}, + tabindex: (pressed || @group_context[:roving_first]) ? "0" : "-1" + } + else + { + aria: {pressed: pressed.to_s}, + tabindex: "0" + } + end + + base_classes_attrs.merge(role_attrs).merge( + data: { + state: pressed ? "on" : "off", + value: @item_value, + "ruby-ui--toggle-group-target": "item", + action: "click->ruby-ui--toggle-group#select keydown->ruby-ui--toggle-group#navigate" + } + ).tap do |h| + # Strip Toggle-primitive's standalone controller wiring — group owns state + h.delete(:controller) if h[:controller] + if h[:data].is_a?(Hash) + h[:data].delete(:controller) if h[:data][:controller] + h[:data].delete(:"ruby-ui--toggle-pressed-value") + h[:data].delete(:"ruby-ui--toggle-value-value") + h[:data].delete(:"ruby-ui--toggle-unpressed-value-value") + end + # For :single, replace aria-pressed (set by parent default_attrs) with aria-checked semantics + if type == :single && h[:aria].is_a?(Hash) + h[:aria].delete(:pressed) + end + end + end + end +end diff --git a/gem/test/ruby_ui/toggle_group_test.rb b/gem/test/ruby_ui/toggle_group_test.rb new file mode 100644 index 00000000..6c9df581 --- /dev/null +++ b/gem/test/ruby_ui/toggle_group_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::ToggleGroupTest < ComponentTest + def test_single_uses_radiogroup_role + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "align") do |g| + g.ToggleGroupItem(value: "left") { "L" } + g.ToggleGroupItem(value: "right") { "R" } + end + end + assert_match(/role="radiogroup"/, output) + assert_match(/role="radio"/, output) + end + + def test_multiple_uses_group_role_with_aria_pressed + output = phlex do + RubyUI.ToggleGroup(type: :multiple, name: "fmt") do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + end + end + assert_match(/role="group"/, output) + assert_match(/aria-pressed=/, output) + refute_match(/role="radio"/, output) + end + + def test_single_initial_value_sets_pressed_item + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "align", value: "right") do |g| + g.ToggleGroupItem(value: "left") { "L" } + g.ToggleGroupItem(value: "right") { "R" } + end + end + # right item is pressed + assert_match(/data-value="right"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-value="right"/, output) + # exactly one hidden input with selected value + assert_match(/]*type="hidden"[^>]*name="align"[^>]*value="right"/, output) + end + + def test_multiple_initial_value_array_pressed + output = phlex do + RubyUI.ToggleGroup(type: :multiple, name: "fmt", value: %w[bold italic]) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + end + assert_match(/]*name="fmt\[\]"[^>]*value="bold"/, output) + assert_match(/]*name="fmt\[\]"[^>]*value="italic"/, output) + refute_match(/]*name="fmt\[\]"[^>]*value="underline"/, output) + end + + def test_single_roving_tabindex + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "align", value: "left") do |g| + g.ToggleGroupItem(value: "left") { "L" } + g.ToggleGroupItem(value: "right") { "R" } + end + end + assert_equal 1, output.scan('tabindex="0"').size + assert_match(/tabindex="-1"/, output) + end + + def test_disabled_group_disables_all_items + output = phlex do + RubyUI.ToggleGroup(type: :multiple, name: "fmt", disabled: true) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + end + end + assert_equal 2, output.scan(/]*disabled/).size + end + + def test_group_controller_attached + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "align") do |g| + g.ToggleGroupItem(value: "left") { "L" } + end + end + assert_match(/data-controller="[^"]*ruby-ui--toggle-group/, output) + assert_match(/data-ruby-ui--toggle-group-type-value="single"/, output) + assert_match(/data-ruby-ui--toggle-group-name-value="align"/, output) + end + + def test_group_items_dont_have_standalone_toggle_controller + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "align") do |g| + g.ToggleGroupItem(value: "left") { "L" } + end + end + # group controller present on wrapper, but item buttons should not be tagged with single-toggle controller + refute_match(/]*data-controller="[^"]*ruby-ui--toggle"/, output) + end +end From 15e141da1677f6c0da81a8dab2c215ab4cf2d6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:10:18 -0300 Subject: [PATCH 05/22] [Feature] ToggleGroup Stimulus controller (single/multiple, roving tabindex, form sync) --- .../toggle_group/toggle_group_controller.js | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 gem/lib/ruby_ui/toggle_group/toggle_group_controller.js diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js b/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js new file mode 100644 index 00000000..00121ec0 --- /dev/null +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_controller.js @@ -0,0 +1,126 @@ +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 } + + connect() { + this.reconcile() + } + + select(event) { + const item = event.currentTarget + if (item.disabled) return + + if (this.typeValue === "single") { + this.itemTargets.forEach(el => this.setPressed(el, el === item)) + } else { + this.setPressed(item, !this.isPressed(item)) + } + + this.rebuildInputs() + this.updateRovingTabindex(item) + } + + navigate(event) { + if (this.typeValue !== "single") return + const items = this.enabledItems() + if (items.length === 0) return + + const isRtl = document.documentElement.dir === "rtl" + const currentIndex = items.indexOf(event.currentTarget) + let nextIndex = currentIndex + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + nextIndex = (currentIndex + (isRtl && event.key === "ArrowRight" ? -1 : 1) + items.length) % items.length + break + case "ArrowLeft": + case "ArrowUp": + nextIndex = (currentIndex + (isRtl && event.key === "ArrowLeft" ? 1 : -1) + items.length) % items.length + break + case "Home": + nextIndex = 0 + break + case "End": + nextIndex = items.length - 1 + break + case " ": + case "Enter": + event.preventDefault() + event.currentTarget.click() + return + default: + return + } + + event.preventDefault() + const next = items[nextIndex] + this.updateRovingTabindex(next) + next.focus() + } + + reconcile() { + if (this.typeValue === "single") { + const pressed = this.itemTargets.find(el => this.isPressed(el)) + const first = pressed || this.enabledItems()[0] + this.itemTargets.forEach(el => { + el.setAttribute("tabindex", el === first ? "0" : "-1") + }) + } else { + this.itemTargets.forEach(el => el.setAttribute("tabindex", "0")) + } + this.rebuildInputs() + } + + isPressed(item) { + return item.dataset.state === "on" + } + + setPressed(item, pressed) { + item.dataset.state = pressed ? "on" : "off" + if (this.typeValue === "single") { + item.setAttribute("aria-checked", pressed ? "true" : "false") + } else { + item.setAttribute("aria-pressed", pressed ? "true" : "false") + } + } + + updateRovingTabindex(focusedItem) { + if (this.typeValue !== "single") return + this.itemTargets.forEach(el => { + el.setAttribute("tabindex", el === focusedItem ? "0" : "-1") + }) + } + + enabledItems() { + return this.itemTargets.filter(el => !el.disabled) + } + + rebuildInputs() { + if (!this.nameValue) return + this.inputTargets.forEach(el => el.remove()) + + const pressed = this.itemTargets.filter(el => this.isPressed(el)) + + if (this.typeValue === "single") { + const val = pressed[0]?.dataset.value || "" + this.element.appendChild(this.buildInput(this.nameValue, val)) + } else { + pressed.forEach(item => { + this.element.appendChild(this.buildInput(`${this.nameValue}[]`, item.dataset.value)) + }) + } + } + + buildInput(name, value) { + const input = document.createElement("input") + input.type = "hidden" + input.name = name + input.value = value + input.setAttribute("data-ruby-ui--toggle-group-target", "input") + return input + } +} From 5ed1d70bde44235cfca6ca1d47d1dcf091126e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:11:52 -0300 Subject: [PATCH 06/22] [Refactor] ThemeToggle composes Toggle; drop SetDarkMode/SetLightMode --- gem/lib/ruby_ui/theme_toggle/set_dark_mode.rb | 16 ------ .../ruby_ui/theme_toggle/set_light_mode.rb | 16 ------ gem/lib/ruby_ui/theme_toggle/theme_toggle.rb | 14 ++++- .../theme_toggle/theme_toggle_controller.js | 46 +++++++++------- .../ruby_ui/theme_toggle/theme_toggle_docs.rb | 54 +++++-------------- gem/test/ruby_ui/theme_toggle_test.rb | 26 ++++----- 6 files changed, 65 insertions(+), 107 deletions(-) delete mode 100644 gem/lib/ruby_ui/theme_toggle/set_dark_mode.rb delete mode 100644 gem/lib/ruby_ui/theme_toggle/set_light_mode.rb diff --git a/gem/lib/ruby_ui/theme_toggle/set_dark_mode.rb b/gem/lib/ruby_ui/theme_toggle/set_dark_mode.rb deleted file mode 100644 index 3b307651..00000000 --- a/gem/lib/ruby_ui/theme_toggle/set_dark_mode.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class SetDarkMode < Base - def view_template(&) - div(**attrs, &) - end - - def default_attrs - { - class: "hidden dark:inline-block", - data: {controller: "ruby-ui--theme-toggle", action: "click->ruby-ui--theme-toggle#setLightTheme"} - } - end - end -end diff --git a/gem/lib/ruby_ui/theme_toggle/set_light_mode.rb b/gem/lib/ruby_ui/theme_toggle/set_light_mode.rb deleted file mode 100644 index 00f88fbf..00000000 --- a/gem/lib/ruby_ui/theme_toggle/set_light_mode.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class SetLightMode < Base - def view_template(&) - div(**attrs, &) - end - - def default_attrs - { - class: "dark:hidden", - data: {controller: "ruby-ui--theme-toggle", action: "click->ruby-ui--theme-toggle#setDarkTheme"} - } - end - end -end diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb index 000f8054..1dae08e9 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb @@ -2,8 +2,18 @@ module RubyUI class ThemeToggle < Base - def view_template(&) - div(**attrs, &) + def view_template(&block) + RubyUI.Toggle( + variant: :outline, + size: :default, + aria: {label: "Toggle theme"}, + data: { + controller: "ruby-ui--toggle ruby-ui--theme-toggle", + action: "ruby-ui:toggle:change->ruby-ui--theme-toggle#apply" + }, + **attrs, + &block + ) end end end diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js index 01b3b24b..3217c37c 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js @@ -1,30 +1,38 @@ import { Controller } from "@hotwired/stimulus" +// Connects to data-controller="ruby-ui--theme-toggle" +// Expects to sit on the same element as ruby-ui--toggle and listen to its +// ruby-ui:toggle:change event. pressed = dark mode. export default class extends Controller { - initialize() { - this.setTheme() + connect() { + this.applyTheme(this.currentTheme()) } - setTheme() { - // On page load or when changing themes, best to add inline in `head` to avoid FOUC - if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - document.documentElement.classList.add('dark') - document.documentElement.classList.remove('light') - } else { - document.documentElement.classList.remove('dark') - document.documentElement.classList.add('light') - } + apply(event) { + const pressed = event.detail?.pressed + const theme = pressed ? "dark" : "light" + localStorage.theme = theme + this.applyTheme(theme) } - setLightTheme() { - // Whenever the user explicitly chooses light mode - localStorage.theme = 'light' - this.setTheme() + currentTheme() { + if (localStorage.theme === "dark") return "dark" + if (localStorage.theme === "light") return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } - setDarkTheme() { - // Whenever the user explicitly chooses dark mode - localStorage.theme = 'dark' - this.setTheme() + applyTheme(theme) { + const html = document.documentElement + if (theme === "dark") { + html.classList.add("dark") + html.classList.remove("light") + } else { + html.classList.add("light") + html.classList.remove("dark") + } + const dark = theme === "dark" + this.element.setAttribute("aria-pressed", dark ? "true" : "false") + this.element.dataset.state = dark ? "on" : "off" + this.element.dataset["rubyUi--TogglePressedValue"] = dark ? "true" : "false" } } diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb b/gem/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb index 1740a924..35bd07ae 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb @@ -11,39 +11,17 @@ def view_template render Docs::VisualCodeExample.new(title: "With icon", context: self) do <<~RUBY - ThemeToggle do |toggle| - SetLightMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - d: - "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" - ) - end - end - end - - SetDarkMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - fill_rule: "evenodd", - d: - "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", - clip_rule: "evenodd" - ) - end - end + ThemeToggle do + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-4 h-4" + ) do |s| + s.path( + d: + "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" + ) end end RUBY @@ -51,15 +29,7 @@ def view_template render Docs::VisualCodeExample.new(title: "With text", context: self) do <<~RUBY - ThemeToggle do |toggle| - SetLightMode do - Button(variant: :primary) { "Light" } - end - - SetDarkMode do - Button(variant: :primary) { "Dark" } - end - end + ThemeToggle { "Toggle Theme" } RUBY end diff --git a/gem/test/ruby_ui/theme_toggle_test.rb b/gem/test/ruby_ui/theme_toggle_test.rb index 3ad57de8..05b2a2a9 100644 --- a/gem/test/ruby_ui/theme_toggle_test.rb +++ b/gem/test/ruby_ui/theme_toggle_test.rb @@ -3,19 +3,21 @@ require "test_helper" class RubyUI::ThemeToggleTest < ComponentTest - def test_render_with_all_items - output = phlex do - RubyUI.ThemeToggle do |toggle| - RubyUI.SetLightMode do - RubyUI.Button(variant: :primary) { "Light" } - end + def test_renders_as_toggle_button + output = phlex { RubyUI.ThemeToggle { "icon" } } + assert_match(/]*type="button"/, output) + assert_match(/aria-pressed=/, output) + end - RubyUI.SetDarkMode do - RubyUI.Button(variant: :primary) { "Dark" } - end - end - end + def test_wires_theme_toggle_controller + output = phlex { RubyUI.ThemeToggle { "icon" } } + assert_match(/data-controller="[^"]*ruby-ui--theme-toggle/, output) + assert_match(/data-controller="[^"]*ruby-ui--toggle/, output) + assert_match(/ruby-ui:toggle:change->ruby-ui--theme-toggle#apply/, output) + end - assert_match(/Dark/, output) + def test_block_content_rendered + output = phlex { RubyUI.ThemeToggle { "SUN_AND_MOON" } } + assert_match(/SUN_AND_MOON/, output) end end From 97222b22024bba467dd7bb0b01a72284e7e754f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:13:08 -0300 Subject: [PATCH 07/22] [Documentation] Add Toggle docs page --- docs/app/components/shared/components_list.rb | 2 + docs/app/controllers/docs_controller.rb | 6 ++ docs/app/lib/site_files.rb | 2 + docs/app/views/docs/toggle.rb | 58 +++++++++++++++++++ docs/config/routes.rb | 2 + 5 files changed, 70 insertions(+) create mode 100644 docs/app/views/docs/toggle.rb diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index 9f2775c1..c46e8f9f 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -50,6 +50,8 @@ def components {name: "Tabs", path: docs_tabs_path}, {name: "Textarea", path: docs_textarea_path}, {name: "Theme Toggle", path: docs_theme_toggle_path}, + {name: "Toggle", path: docs_toggle_path}, + {name: "Toggle Group", path: docs_toggle_group_path}, {name: "Tooltip", path: docs_tooltip_path}, {name: "Typography", path: docs_typography_path} ] diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index f60ca123..cfd546d6 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -222,6 +222,12 @@ def theme_toggle render Views::Docs::ThemeToggle.new end + def toggle + end + + def toggle_group + end + def tooltip render Views::Docs::Tooltip.new end diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index 2a6827e7..70977d32 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -116,6 +116,8 @@ class SiteFiles {title: "Tabs", path: "/docs/tabs", description: "Layered tab panels displayed one at a time."}, {title: "Textarea", path: "/docs/textarea", description: "Styled multiline text input."}, {title: "Theme Toggle", path: "/docs/theme_toggle", description: "Toggle control for switching between light and dark themes."}, + {title: "Toggle", path: "/docs/toggle", description: "Two-state button that can be either on or off."}, + {title: "Toggle Group", path: "/docs/toggle_group", description: "Group of two-state toggle buttons."}, {title: "Tooltip", path: "/docs/tooltip", description: "Popup information shown on keyboard focus or hover."}, {title: "Typography", path: "/docs/typography", description: "Text primitives and sensible typography defaults."} ].freeze diff --git a/docs/app/views/docs/toggle.rb b/docs/app/views/docs/toggle.rb new file mode 100644 index 00000000..6865aff6 --- /dev/null +++ b/docs/app/views/docs/toggle.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Views::Docs::Toggle < Views::Base + def view_template + component = "Toggle" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "Toggle", + description: "A two-state button that can be either on or off." + ) + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + Toggle { "Bold" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Outline", context: self) do + <<~RUBY + Toggle(variant: :outline) { "Italic" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Sizes", context: self) do + <<~RUBY + div(class: "flex items-center gap-2") do + Toggle(size: :sm) { "Small" } + Toggle(size: :default) { "Default" } + Toggle(size: :lg) { "Large" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Pressed", context: self) do + <<~RUBY + Toggle(pressed: true) { "Pressed" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + Toggle(disabled: true) { "Disabled" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "In a form", context: self) do + <<~RUBY + Toggle(name: "bold", value: "1") { "Bold" } + RUBY + end + + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 71d5ed58..d1c0c845 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -66,6 +66,8 @@ get "tabs", to: "docs#tabs", as: :docs_tabs get "textarea", to: "docs#textarea", as: :docs_textarea get "theme_toggle", to: "docs#theme_toggle", as: :docs_theme_toggle + get "toggle", to: "docs#toggle", as: :docs_toggle + get "toggle_group", to: "docs#toggle_group", as: :docs_toggle_group get "tooltip", to: "docs#tooltip", as: :docs_tooltip get "typography", to: "docs#typography", as: :docs_typography From 2c986f9fe2e9995b2234a8a3a70eb44124997e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:13:46 -0300 Subject: [PATCH 08/22] [Documentation] Add ToggleGroup docs page --- docs/app/views/docs/toggle_group.rb | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/app/views/docs/toggle_group.rb diff --git a/docs/app/views/docs/toggle_group.rb b/docs/app/views/docs/toggle_group.rb new file mode 100644 index 00000000..61c7b164 --- /dev/null +++ b/docs/app/views/docs/toggle_group.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Views::Docs::ToggleGroup < Views::Base + def view_template + component = "ToggleGroup" + + 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." + ) + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Single selection", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "align", value: "left") do |g| + g.ToggleGroupItem(value: "left") { "Left" } + g.ToggleGroupItem(value: "center") { "Center" } + g.ToggleGroupItem(value: "right") { "Right" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Multiple selection", context: self) do + <<~RUBY + ToggleGroup(type: :multiple, name: "fmt", value: %w[bold]) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Outline variant", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "align", variant: :outline) do |g| + g.ToggleGroupItem(value: "left") { "Left" } + g.ToggleGroupItem(value: "center") { "Center" } + g.ToggleGroupItem(value: "right") { "Right" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Sizes", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "size", size: :sm) do |g| + g.ToggleGroupItem(value: "a") { "A" } + g.ToggleGroupItem(value: "b") { "B" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + ToggleGroup(type: :multiple, name: "fmt", disabled: true) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + end + RUBY + end + + render Docs::ComponentsTable.new(component_files(component)) + end + end +end From 80d5ea7dfb6bd29792d05ee2f4560b41732bc01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:14:44 -0300 Subject: [PATCH 09/22] [Documentation] Regenerate site files for toggle pages --- docs/public/llms-full.txt | 10 ++++++++++ docs/public/llms.txt | 2 ++ docs/public/sitemap.xml | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 49e1b54c..26a43f28 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -298,6 +298,16 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/theme_toggle - Summary: Toggle control for switching between light and dark themes. +### Toggle + +- URL: https://rubyui.com/docs/toggle +- Summary: Two-state button that can be either on or off. + +### Toggle Group + +- URL: https://rubyui.com/docs/toggle_group +- Summary: Group of two-state toggle buttons. + ### Tooltip - URL: https://rubyui.com/docs/tooltip diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 602abddf..481be90c 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -64,6 +64,8 @@ Use the core docs first for installation, theming, dark mode, and customization - [Tabs](https://rubyui.com/docs/tabs): Layered tab panels displayed one at a time. - [Textarea](https://rubyui.com/docs/textarea): Styled multiline text input. - [Theme Toggle](https://rubyui.com/docs/theme_toggle): Toggle control for switching between light and dark themes. +- [Toggle](https://rubyui.com/docs/toggle): Two-state button that can be either on or off. +- [Toggle Group](https://rubyui.com/docs/toggle_group): Group of two-state toggle buttons. - [Tooltip](https://rubyui.com/docs/tooltip): Popup information shown on keyboard focus or hover. - [Typography](https://rubyui.com/docs/typography): Text primitives and sensible typography defaults. diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index 578e4246..ee35c497 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -275,6 +275,16 @@ monthly 0.7 + + https://rubyui.com/docs/toggle + monthly + 0.7 + + + https://rubyui.com/docs/toggle_group + monthly + 0.7 + https://rubyui.com/docs/tooltip monthly From 17c4ccd5661c2bcb207ee1ada9fda8e62789ae45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:17:51 -0300 Subject: [PATCH 10/22] [Bug Fix] Update docs navbar + theme_toggle view to new ThemeToggle API --- docs/app/components/shared/navbar.rb | 52 +++++++++----------- docs/app/views/docs/theme_toggle.rb | 71 +++++++++------------------- 2 files changed, 44 insertions(+), 79 deletions(-) diff --git a/docs/app/components/shared/navbar.rb b/docs/app/components/shared/navbar.rb index c8d45178..513d0d6f 100644 --- a/docs/app/components/shared/navbar.rb +++ b/docs/app/components/shared/navbar.rb @@ -34,37 +34,29 @@ def view_template def dark_mode_toggle ThemeToggle do - SetLightMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-5 h-5" - ) do |s| - s.path( - d: - "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" - ) - end - end + # Sun (visible in light mode) + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-5 h-5 dark:hidden" + ) do |s| + s.path( + d: "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" + ) end - SetDarkMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - fill_rule: "evenodd", - d: - "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", - clip_rule: "evenodd" - ) - end - end + # Moon (visible in dark mode) + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-5 h-5 hidden dark:inline-block" + ) do |s| + s.path( + fill_rule: "evenodd", + d: "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", + clip_rule: "evenodd" + ) end end end diff --git a/docs/app/views/docs/theme_toggle.rb b/docs/app/views/docs/theme_toggle.rb index 1740a924..6573f8df 100644 --- a/docs/app/views/docs/theme_toggle.rb +++ b/docs/app/views/docs/theme_toggle.rb @@ -5,66 +5,39 @@ def view_template component = "ThemeToggle" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do - render Docs::Header.new(title: "Theme Toggle", description: "Toggle between dark/light theme.") + render Docs::Header.new(title: "Theme Toggle", description: "Toggle between dark and light theme. Composes the Toggle component; pressed = dark mode.") Heading(level: 2) { "Usage" } render Docs::VisualCodeExample.new(title: "With icon", context: self) do <<~RUBY - ThemeToggle do |toggle| - SetLightMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - d: - "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" - ) - end - end + ThemeToggle do + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-4 h-4 dark:hidden" + ) do |s| + s.path( + d: "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" + ) end - - SetDarkMode do - Button(variant: :ghost, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - fill_rule: "evenodd", - d: - "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", - clip_rule: "evenodd" - ) - end - end - end - end - RUBY - end - - render Docs::VisualCodeExample.new(title: "With text", context: self) do - <<~RUBY - ThemeToggle do |toggle| - SetLightMode do - Button(variant: :primary) { "Light" } - end - - SetDarkMode do - Button(variant: :primary) { "Dark" } + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-4 h-4 hidden dark:inline-block" + ) do |s| + s.path( + fill_rule: "evenodd", + d: "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", + clip_rule: "evenodd" + ) end end RUBY end - render Components::ComponentSetup::Tabs.new(component_name: component) - render Docs::ComponentsTable.new(component_files(component)) end end From f66e549314da1f0e1caf96e22e7cdb8c24c04979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:18:11 -0300 Subject: [PATCH 11/22] [Bug Fix] Render Toggle/ToggleGroup views from docs controller actions --- docs/app/controllers/docs_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index cfd546d6..20b70c06 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -223,9 +223,11 @@ def theme_toggle end def toggle + render Views::Docs::Toggle.new end def toggle_group + render Views::Docs::ToggleGroup.new end def tooltip From 0191ed0a21a36c3f2155c92790c5ace39ed7764e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:18:16 -0300 Subject: [PATCH 12/22] [Documentation] Ignore specs/ planning directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index aa5a7bc8..9b8b985e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ CLAUDE.local.md +specs/ From 388bec3422ae700a0ea955e2fd2796bf8a2f9363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:50:57 -0300 Subject: [PATCH 13/22] [Bug Fix] Register Toggle/ToggleGroup Stimulus controllers in docs app Copy toggle_controller.js and toggle_group_controller.js from gem into docs/app/javascript/controllers/ruby_ui/ and update theme_toggle_controller.js to the new API (apply action + applyTheme helper replacing setLightTheme/setDarkTheme). Regenerate Stimulus manifest so all three controllers are registered. --- docs/app/javascript/controllers/index.js | 12 +- .../ruby_ui/theme_toggle_controller.js | 46 ++++--- .../controllers/ruby_ui/toggle_controller.js | 27 ++++ .../ruby_ui/toggle_group_controller.js | 126 ++++++++++++++++++ 4 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 docs/app/javascript/controllers/ruby_ui/toggle_controller.js create mode 100644 docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index e68815bd..3fe2204d 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -43,12 +43,12 @@ application.register("ruby-ui--command", RubyUi__CommandController) import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller" application.register("ruby-ui--context-menu", RubyUi__ContextMenuController) -import RubyUi__DataTableController from "./ruby_ui/data_table_controller" -application.register("ruby-ui--data-table", RubyUi__DataTableController) - import RubyUi__DataTableColumnVisibilityController from "./ruby_ui/data_table_column_visibility_controller" application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableColumnVisibilityController) +import RubyUi__DataTableController from "./ruby_ui/data_table_controller" +application.register("ruby-ui--data-table", RubyUi__DataTableController) + import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller" application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController) @@ -91,6 +91,12 @@ application.register("ruby-ui--tabs", RubyUi__TabsController) import RubyUi__ThemeToggleController from "./ruby_ui/theme_toggle_controller" application.register("ruby-ui--theme-toggle", RubyUi__ThemeToggleController) +import RubyUi__ToggleController from "./ruby_ui/toggle_controller" +application.register("ruby-ui--toggle", RubyUi__ToggleController) + +import RubyUi__ToggleGroupController from "./ruby_ui/toggle_group_controller" +application.register("ruby-ui--toggle-group", RubyUi__ToggleGroupController) + import RubyUi__TooltipController from "./ruby_ui/tooltip_controller" application.register("ruby-ui--tooltip", RubyUi__TooltipController) diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js index 01b3b24b..3217c37c 100644 --- a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js @@ -1,30 +1,38 @@ import { Controller } from "@hotwired/stimulus" +// Connects to data-controller="ruby-ui--theme-toggle" +// Expects to sit on the same element as ruby-ui--toggle and listen to its +// ruby-ui:toggle:change event. pressed = dark mode. export default class extends Controller { - initialize() { - this.setTheme() + connect() { + this.applyTheme(this.currentTheme()) } - setTheme() { - // On page load or when changing themes, best to add inline in `head` to avoid FOUC - if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - document.documentElement.classList.add('dark') - document.documentElement.classList.remove('light') - } else { - document.documentElement.classList.remove('dark') - document.documentElement.classList.add('light') - } + apply(event) { + const pressed = event.detail?.pressed + const theme = pressed ? "dark" : "light" + localStorage.theme = theme + this.applyTheme(theme) } - setLightTheme() { - // Whenever the user explicitly chooses light mode - localStorage.theme = 'light' - this.setTheme() + currentTheme() { + if (localStorage.theme === "dark") return "dark" + if (localStorage.theme === "light") return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } - setDarkTheme() { - // Whenever the user explicitly chooses dark mode - localStorage.theme = 'dark' - this.setTheme() + applyTheme(theme) { + const html = document.documentElement + if (theme === "dark") { + html.classList.add("dark") + html.classList.remove("light") + } else { + html.classList.add("light") + html.classList.remove("dark") + } + const dark = theme === "dark" + this.element.setAttribute("aria-pressed", dark ? "true" : "false") + this.element.dataset.state = dark ? "on" : "off" + this.element.dataset["rubyUi--TogglePressedValue"] = dark ? "true" : "false" } } diff --git a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js new file mode 100644 index 00000000..0cacd437 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="ruby-ui--toggle" +export default class extends Controller { + static targets = ["input"] + static values = { + pressed: Boolean, + value: String, + unpressedValue: String + } + + toggle(event) { + if (this.element.disabled) return + this.pressedValue = !this.pressedValue + } + + pressedValueChanged(current) { + this.element.setAttribute("aria-pressed", current ? "true" : "false") + this.element.dataset.state = current ? "on" : "off" + + if (this.hasInputTarget) { + this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue + } + + this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + } +} diff --git a/docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js b/docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js new file mode 100644 index 00000000..00121ec0 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js @@ -0,0 +1,126 @@ +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 } + + connect() { + this.reconcile() + } + + select(event) { + const item = event.currentTarget + if (item.disabled) return + + if (this.typeValue === "single") { + this.itemTargets.forEach(el => this.setPressed(el, el === item)) + } else { + this.setPressed(item, !this.isPressed(item)) + } + + this.rebuildInputs() + this.updateRovingTabindex(item) + } + + navigate(event) { + if (this.typeValue !== "single") return + const items = this.enabledItems() + if (items.length === 0) return + + const isRtl = document.documentElement.dir === "rtl" + const currentIndex = items.indexOf(event.currentTarget) + let nextIndex = currentIndex + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + nextIndex = (currentIndex + (isRtl && event.key === "ArrowRight" ? -1 : 1) + items.length) % items.length + break + case "ArrowLeft": + case "ArrowUp": + nextIndex = (currentIndex + (isRtl && event.key === "ArrowLeft" ? 1 : -1) + items.length) % items.length + break + case "Home": + nextIndex = 0 + break + case "End": + nextIndex = items.length - 1 + break + case " ": + case "Enter": + event.preventDefault() + event.currentTarget.click() + return + default: + return + } + + event.preventDefault() + const next = items[nextIndex] + this.updateRovingTabindex(next) + next.focus() + } + + reconcile() { + if (this.typeValue === "single") { + const pressed = this.itemTargets.find(el => this.isPressed(el)) + const first = pressed || this.enabledItems()[0] + this.itemTargets.forEach(el => { + el.setAttribute("tabindex", el === first ? "0" : "-1") + }) + } else { + this.itemTargets.forEach(el => el.setAttribute("tabindex", "0")) + } + this.rebuildInputs() + } + + isPressed(item) { + return item.dataset.state === "on" + } + + setPressed(item, pressed) { + item.dataset.state = pressed ? "on" : "off" + if (this.typeValue === "single") { + item.setAttribute("aria-checked", pressed ? "true" : "false") + } else { + item.setAttribute("aria-pressed", pressed ? "true" : "false") + } + } + + updateRovingTabindex(focusedItem) { + if (this.typeValue !== "single") return + this.itemTargets.forEach(el => { + el.setAttribute("tabindex", el === focusedItem ? "0" : "-1") + }) + } + + enabledItems() { + return this.itemTargets.filter(el => !el.disabled) + } + + rebuildInputs() { + if (!this.nameValue) return + this.inputTargets.forEach(el => el.remove()) + + const pressed = this.itemTargets.filter(el => this.isPressed(el)) + + if (this.typeValue === "single") { + const val = pressed[0]?.dataset.value || "" + this.element.appendChild(this.buildInput(this.nameValue, val)) + } else { + pressed.forEach(item => { + this.element.appendChild(this.buildInput(`${this.nameValue}[]`, item.dataset.value)) + }) + } + } + + buildInput(name, value) { + const input = document.createElement("input") + input.type = "hidden" + input.name = name + input.value = value + input.setAttribute("data-ruby-ui--toggle-group-target", "input") + return input + } +} From 7d2e27fe6edc98115cdd090c7c305f095c564ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 19:56:32 -0300 Subject: [PATCH 14/22] [Feature] ToggleGroup match shadcn look (joined default, spacing, orientation) --- docs/app/views/docs/toggle_group.rb | 44 +++++++++++++++ gem/lib/ruby_ui/toggle_group/toggle_group.rb | 34 +++++++++++- .../ruby_ui/toggle_group/toggle_group_item.rb | 17 ++++++ gem/test/ruby_ui/toggle_group_test.rb | 55 +++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/docs/app/views/docs/toggle_group.rb b/docs/app/views/docs/toggle_group.rb index 61c7b164..77b2c25b 100644 --- a/docs/app/views/docs/toggle_group.rb +++ b/docs/app/views/docs/toggle_group.rb @@ -32,6 +32,50 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Spacing", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "align", value: "left", 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" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Vertical", context: self) do + <<~RUBY + ToggleGroup(type: :multiple, name: "fmt", orientation: :vertical, value: %w[bold]) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Custom", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "weight", value: "normal", variant: :outline, spacing: 2) do |g| + g.ToggleGroupItem(value: "light", size: :lg, class: "h-auto flex-col gap-1 py-2") do + span(class: "text-base font-light") { "Aa" } + span(class: "text-xs text-muted-foreground") { "Light" } + end + g.ToggleGroupItem(value: "normal", size: :lg, class: "h-auto flex-col gap-1 py-2") do + span(class: "text-base font-normal") { "Aa" } + span(class: "text-xs text-muted-foreground") { "Normal" } + end + g.ToggleGroupItem(value: "medium", size: :lg, class: "h-auto flex-col gap-1 py-2") do + span(class: "text-base font-medium") { "Aa" } + span(class: "text-xs text-muted-foreground") { "Medium" } + end + g.ToggleGroupItem(value: "bold", size: :lg, class: "h-auto flex-col gap-1 py-2") do + span(class: "text-base font-bold") { "Aa" } + span(class: "text-xs text-muted-foreground") { "Bold" } + end + end + RUBY + end + render Docs::VisualCodeExample.new(title: "Outline variant", context: self) do <<~RUBY ToggleGroup(type: :single, name: "align", variant: :outline) do |g| diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group.rb b/gem/lib/ruby_ui/toggle_group/toggle_group.rb index e8b97d93..d4d560b1 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group.rb @@ -9,6 +9,8 @@ def initialize( variant: :default, size: :default, disabled: false, + spacing: 0, + orientation: :horizontal, **attrs ) @type = type.to_sym @@ -18,6 +20,10 @@ def initialize( @variant = variant.to_sym @size = size.to_sym @disabled = disabled + @spacing = spacing + raise ArgumentError, "spacing must be an Integer 0..4" unless @spacing.is_a?(Integer) && (0..4).cover?(@spacing) + @orientation = orientation.to_sym + raise ArgumentError, "orientation must be :horizontal or :vertical" unless [:horizontal, :vertical].include?(@orientation) super(**attrs) end @@ -38,7 +44,9 @@ def item_context size: @size, disabled: @disabled, selected_values: selected_values, - roving_first: !@first_item_emitted + roving_first: !@first_item_emitted, + spacing: @spacing, + orientation: @orientation } end @@ -84,14 +92,34 @@ def render_hidden_inputs end def default_attrs + base_class = if @orientation == :vertical + "flex w-fit flex-col items-stretch rounded-md" + else + "flex w-fit items-center rounded-md" + end + + gap_class = case @spacing + when 0 then nil + when 1 then "gap-1" + when 2 then "gap-2" + when 3 then "gap-3" + when 4 then "gap-4" + end + + shadow_class = (@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil + + classes = [base_class, gap_class, shadow_class].compact.join(" ") + { 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 + "ruby-ui--toggle-group-name-value": @name.to_s, + orientation: @orientation.to_s, + spacing: @spacing.to_s }, - class: "inline-flex items-center justify-center gap-1" + class: classes } end diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb index 3338cd37..5991e5ac 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb @@ -67,6 +67,23 @@ def default_attrs if type == :single && h[:aria].is_a?(Hash) h[:aria].delete(:pressed) end + + # Append item-level classes for shadcn joined/spaced look + extra = ["w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10"] + spacing = @group_context[:spacing] + orientation = @group_context[:orientation] + variant = @group_context[:variant] + if spacing == 0 + extra << "rounded-none shadow-none" + if orientation == :vertical + extra << "first:rounded-t-md last:rounded-b-md" + extra << "border-t-0 first:border-t" if variant == :outline + else + extra << "first:rounded-l-md last:rounded-r-md" + extra << "border-l-0 first:border-l" if variant == :outline + end + end + h[:class] = [h[:class], *extra].flatten.compact end end end diff --git a/gem/test/ruby_ui/toggle_group_test.rb b/gem/test/ruby_ui/toggle_group_test.rb index 6c9df581..0f17e89b 100644 --- a/gem/test/ruby_ui/toggle_group_test.rb +++ b/gem/test/ruby_ui/toggle_group_test.rb @@ -93,4 +93,59 @@ def test_group_items_dont_have_standalone_toggle_controller # group controller present on wrapper, but item buttons should not be tagged with single-toggle controller refute_match(/]*data-controller="[^"]*ruby-ui--toggle"/, output) end + + def test_joined_items_have_first_last_rounded + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "x") do |g| + g.ToggleGroupItem(value: "a") { "A" } + g.ToggleGroupItem(value: "b") { "B" } + end + end + assert_match(/rounded-none/, output) + assert_match(/first:rounded-l-md/, output) + assert_match(/last:rounded-r-md/, output) + end + + def test_spacing_adds_gap_class + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "x", spacing: 2) do |g| + g.ToggleGroupItem(value: "a") { "A" } + g.ToggleGroupItem(value: "b") { "B" } + end + end + assert_match(/gap-2/, output) + refute_match(/rounded-none/, output) + end + + def test_vertical_orientation + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "x", orientation: :vertical) do |g| + g.ToggleGroupItem(value: "a") { "A" } + g.ToggleGroupItem(value: "b") { "B" } + end + end + assert_match(/flex-col/, output) + assert_match(/first:rounded-t-md/, output) + end + + def test_outline_joined_adds_shadow_xs + output = phlex do + RubyUI.ToggleGroup(type: :single, name: "x", variant: :outline) do |g| + g.ToggleGroupItem(value: "a") { "A" } + end + end + assert_match(/shadow-xs/, output) + assert_match(/border-l-0/, output) + assert_match(/first:border-l/, output) + end + + def test_invalid_orientation_raises + assert_raises(ArgumentError) do + phlex do + RubyUI.ToggleGroup(type: :single, name: "x", orientation: :diagonal) do |g| + g.ToggleGroupItem(value: "a") { "A" } + end + end + end + end end From e02135ce8f32fe699aaa92c7e0211f2f97e04a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 20:17:05 -0300 Subject: [PATCH 15/22] [Bug Fix] ToggleGroup joined corners use first-of-type/last-of-type; align docs examples with shadcn --- docs/app/views/docs/toggle_group.rb | 89 +++++++++++-------- .../ruby_ui/toggle_group/toggle_group_item.rb | 8 +- gem/test/ruby_ui/toggle_group_test.rb | 8 +- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/docs/app/views/docs/toggle_group.rb b/docs/app/views/docs/toggle_group.rb index 77b2c25b..9c799f62 100644 --- a/docs/app/views/docs/toggle_group.rb +++ b/docs/app/views/docs/toggle_group.rb @@ -7,24 +7,24 @@ 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." + description: "A set of two-state buttons that can be toggled on or off, with single or multiple selection. Mirrors shadcn/ui ToggleGroup." ) Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Single selection", context: self) do + render Docs::VisualCodeExample.new(title: "Default", context: self) do <<~RUBY - ToggleGroup(type: :single, name: "align", value: "left") do |g| - g.ToggleGroupItem(value: "left") { "Left" } - g.ToggleGroupItem(value: "center") { "Center" } - g.ToggleGroupItem(value: "right") { "Right" } + ToggleGroup(type: :single, name: "letter", value: "a") do |g| + g.ToggleGroupItem(value: "a") { "A" } + g.ToggleGroupItem(value: "b") { "B" } + g.ToggleGroupItem(value: "c") { "C" } end RUBY end - render Docs::VisualCodeExample.new(title: "Multiple selection", context: self) do + render Docs::VisualCodeExample.new(title: "Outline", context: self) do <<~RUBY - ToggleGroup(type: :multiple, name: "fmt", value: %w[bold]) do |g| + ToggleGroup(type: :multiple, name: "fmt", variant: :outline, value: %w[bold]) do |g| g.ToggleGroupItem(value: "bold") { "B" } g.ToggleGroupItem(value: "italic") { "I" } g.ToggleGroupItem(value: "underline") { "U" } @@ -32,9 +32,40 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Single", context: self) do + <<~RUBY + ToggleGroup(type: :single, name: "view", value: "all", variant: :outline) do |g| + g.ToggleGroupItem(value: "all") { "All" } + g.ToggleGroupItem(value: "missed") { "Missed" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Size", context: self) do + <<~RUBY + div(class: "flex flex-col gap-4") do + ToggleGroup(type: :multiple, name: "fmt_sm", variant: :outline, size: :sm) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + ToggleGroup(type: :multiple, name: "fmt_default", variant: :outline) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + ToggleGroup(type: :multiple, name: "fmt_lg", variant: :outline, size: :lg) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + end + RUBY + end + render Docs::VisualCodeExample.new(title: "Spacing", context: self) do <<~RUBY - ToggleGroup(type: :single, name: "align", value: "left", variant: :outline, spacing: 2) do |g| + 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" } @@ -45,7 +76,17 @@ def view_template render Docs::VisualCodeExample.new(title: "Vertical", context: self) do <<~RUBY - ToggleGroup(type: :multiple, name: "fmt", orientation: :vertical, value: %w[bold]) do |g| + ToggleGroup(type: :multiple, name: "fmt_v", variant: :outline, orientation: :vertical, value: %w[bold]) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + ToggleGroup(type: :multiple, name: "fmt_d", variant: :outline, disabled: true) do |g| g.ToggleGroupItem(value: "bold") { "B" } g.ToggleGroupItem(value: "italic") { "I" } g.ToggleGroupItem(value: "underline") { "U" } @@ -76,34 +117,6 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Outline variant", context: self) do - <<~RUBY - ToggleGroup(type: :single, name: "align", variant: :outline) do |g| - g.ToggleGroupItem(value: "left") { "Left" } - g.ToggleGroupItem(value: "center") { "Center" } - g.ToggleGroupItem(value: "right") { "Right" } - end - RUBY - end - - render Docs::VisualCodeExample.new(title: "Sizes", context: self) do - <<~RUBY - ToggleGroup(type: :single, name: "size", size: :sm) do |g| - g.ToggleGroupItem(value: "a") { "A" } - g.ToggleGroupItem(value: "b") { "B" } - end - RUBY - end - - render Docs::VisualCodeExample.new(title: "Disabled", context: self) do - <<~RUBY - ToggleGroup(type: :multiple, name: "fmt", disabled: true) do |g| - g.ToggleGroupItem(value: "bold") { "B" } - g.ToggleGroupItem(value: "italic") { "I" } - end - RUBY - end - render Docs::ComponentsTable.new(component_files(component)) end end diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb index 5991e5ac..bd961ad3 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb @@ -76,11 +76,11 @@ def default_attrs if spacing == 0 extra << "rounded-none shadow-none" if orientation == :vertical - extra << "first:rounded-t-md last:rounded-b-md" - extra << "border-t-0 first:border-t" if variant == :outline + extra << "first-of-type:rounded-t-md last-of-type:rounded-b-md" + extra << "border-t-0 first-of-type:border-t" if variant == :outline else - extra << "first:rounded-l-md last:rounded-r-md" - extra << "border-l-0 first:border-l" if variant == :outline + extra << "first-of-type:rounded-l-md last-of-type:rounded-r-md" + extra << "border-l-0 first-of-type:border-l" if variant == :outline end end h[:class] = [h[:class], *extra].flatten.compact diff --git a/gem/test/ruby_ui/toggle_group_test.rb b/gem/test/ruby_ui/toggle_group_test.rb index 0f17e89b..d7ebfc9f 100644 --- a/gem/test/ruby_ui/toggle_group_test.rb +++ b/gem/test/ruby_ui/toggle_group_test.rb @@ -102,8 +102,8 @@ def test_joined_items_have_first_last_rounded end end assert_match(/rounded-none/, output) - assert_match(/first:rounded-l-md/, output) - assert_match(/last:rounded-r-md/, output) + assert_match(/first-of-type:rounded-l-md/, output) + assert_match(/last-of-type:rounded-r-md/, output) end def test_spacing_adds_gap_class @@ -125,7 +125,7 @@ def test_vertical_orientation end end assert_match(/flex-col/, output) - assert_match(/first:rounded-t-md/, output) + assert_match(/first-of-type:rounded-t-md/, output) end def test_outline_joined_adds_shadow_xs @@ -136,7 +136,7 @@ def test_outline_joined_adds_shadow_xs end assert_match(/shadow-xs/, output) assert_match(/border-l-0/, output) - assert_match(/first:border-l/, output) + assert_match(/first-of-type:border-l/, output) end def test_invalid_orientation_raises From 47836a09691c8768546384c27c7e0af41385764a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 20:33:20 -0300 Subject: [PATCH 16/22] [Documentation] Add Multiple selection example to Toggle Group docs --- docs/app/views/docs/toggle_group.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/app/views/docs/toggle_group.rb b/docs/app/views/docs/toggle_group.rb index 9c799f62..77f15edf 100644 --- a/docs/app/views/docs/toggle_group.rb +++ b/docs/app/views/docs/toggle_group.rb @@ -32,6 +32,16 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Multiple", context: self) do + <<~RUBY + ToggleGroup(type: :multiple, name: "fmt_m", variant: :outline, value: %w[bold italic]) do |g| + g.ToggleGroupItem(value: "bold") { "B" } + g.ToggleGroupItem(value: "italic") { "I" } + g.ToggleGroupItem(value: "underline") { "U" } + end + RUBY + end + render Docs::VisualCodeExample.new(title: "Single", context: self) do <<~RUBY ToggleGroup(type: :single, name: "view", value: "all", variant: :outline) do |g| From 1343f31cb67ffe7296ab1b83097a76aa38970d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 20:39:44 -0300 Subject: [PATCH 17/22] [Refactor] Simplify Toggle/ToggleGroup: extract class builders, drop indirections + roving state --- gem/lib/ruby_ui/toggle/toggle.rb | 48 ++++----- gem/lib/ruby_ui/toggle_group/toggle_group.rb | 72 ++++++-------- .../ruby_ui/toggle_group/toggle_group_item.rb | 97 +++++++------------ gem/test/ruby_ui/toggle_group_test.rb | 6 +- 4 files changed, 87 insertions(+), 136 deletions(-) diff --git a/gem/lib/ruby_ui/toggle/toggle.rb b/gem/lib/ruby_ui/toggle/toggle.rb index 52c418c7..56c671e9 100644 --- a/gem/lib/ruby_ui/toggle/toggle.rb +++ b/gem/lib/ruby_ui/toggle/toggle.rb @@ -13,6 +13,21 @@ class Toggle < Base "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" ].freeze + VARIANT_CLASSES = { + default: "bg-transparent", + outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground" + }.freeze + + SIZE_CLASSES = { + sm: "h-8 min-w-8 px-1.5", + default: "h-9 min-w-9 px-2", + lg: "h-10 min-w-10 px-2.5" + }.freeze + + def self.classes_for(variant:, size:) + [BASE_CLASSES, VARIANT_CLASSES.fetch(variant, VARIANT_CLASSES[:default]), SIZE_CLASSES.fetch(size, SIZE_CLASSES[:default])] + end + def initialize( pressed: false, name: nil, @@ -34,16 +49,12 @@ def initialize( end def view_template(&block) - render_button(&block) + button(**attrs, &block) render_hidden_input if @name end private - def render_button(&block) - button(**attrs, &block) - end - def render_hidden_input input( type: "hidden", @@ -54,9 +65,7 @@ def render_hidden_input end def default_attrs - base = { - type: "button" - } + base = {type: "button"} base[:disabled] = true if @disabled base.merge( aria: {pressed: @pressed.to_s}, @@ -68,29 +77,8 @@ def default_attrs "ruby-ui--toggle-value-value": @value.to_s, "ruby-ui--toggle-unpressed-value-value": @unpressed_value.to_s }, - class: classes + class: self.class.classes_for(variant: @variant, size: @size) ) end - - def classes - [BASE_CLASSES, variant_classes, size_classes] - end - - def variant_classes - case @variant - when :outline - "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground" - else - "bg-transparent" - end - end - - def size_classes - case @size - when :sm then "h-8 min-w-8 px-1.5" - when :lg then "h-10 min-w-10 px-2.5" - else "h-9 min-w-9 px-2" - end - end end end diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group.rb b/gem/lib/ruby_ui/toggle_group/toggle_group.rb index d4d560b1..e2c6f574 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group.rb @@ -2,6 +2,10 @@ module RubyUI class ToggleGroup < Base + SPACING_GAP = {0 => nil, 1 => "gap-1", 2 => "gap-2", 3 => "gap-3", 4 => "gap-4"}.freeze + VALID_TYPES = [:single, :multiple].freeze + VALID_ORIENTATIONS = [:horizontal, :vertical].freeze + def initialize( type: :single, name: nil, @@ -14,29 +18,29 @@ def initialize( **attrs ) @type = type.to_sym - raise ArgumentError, "type must be :single or :multiple" unless [:single, :multiple].include?(@type) + raise ArgumentError, "type must be :single or :multiple" unless VALID_TYPES.include?(@type) + + @orientation = orientation.to_sym + raise ArgumentError, "orientation must be :horizontal or :vertical" unless VALID_ORIENTATIONS.include?(@orientation) + + raise ArgumentError, "spacing must be an Integer 0..4" unless spacing.is_a?(Integer) && (0..4).cover?(spacing) + @name = name @value = value @variant = variant.to_sym @size = size.to_sym @disabled = disabled @spacing = spacing - raise ArgumentError, "spacing must be an Integer 0..4" unless @spacing.is_a?(Integer) && (0..4).cover?(@spacing) - @orientation = orientation.to_sym - raise ArgumentError, "orientation must be :horizontal or :vertical" unless [:horizontal, :vertical].include?(@orientation) super(**attrs) end def view_template(&block) - @first_item_emitted = false div(**attrs) do - yield_content(&block) + yield(self) render_hidden_inputs end end - # Called by ToggleGroupItem during rendering — items use this to fetch - # group context (avoids global state / view-context hackery). def item_context { type: @type, @@ -44,28 +48,21 @@ def item_context size: @size, disabled: @disabled, selected_values: selected_values, - roving_first: !@first_item_emitted, spacing: @spacing, orientation: @orientation } end - def mark_first_item_emitted! - @first_item_emitted = true + def ToggleGroupItem(**kwargs, &block) + render RubyUI::ToggleGroupItem.new(group_context: item_context, **kwargs), &block end private - def yield_content(&block) - yield(self) - end - def selected_values case @type - when :single - @value.nil? ? [] : [@value.to_s] - when :multiple - Array(@value).map(&:to_s) + when :single then @value.nil? ? [] : [@value.to_s] + when :multiple then Array(@value).map(&:to_s) end end @@ -92,24 +89,6 @@ def render_hidden_inputs end def default_attrs - base_class = if @orientation == :vertical - "flex w-fit flex-col items-stretch rounded-md" - else - "flex w-fit items-center rounded-md" - end - - gap_class = case @spacing - when 0 then nil - when 1 then "gap-1" - when 2 then "gap-2" - when 3 then "gap-3" - when 4 then "gap-4" - end - - shadow_class = (@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil - - classes = [base_class, gap_class, shadow_class].compact.join(" ") - { role: (@type == :single) ? "radiogroup" : "group", data: { @@ -119,17 +98,22 @@ def default_attrs orientation: @orientation.to_s, spacing: @spacing.to_s }, - class: classes + class: container_classes } end - public + def container_classes + base = if @orientation == :vertical + "flex w-fit flex-col items-stretch rounded-md" + else + "flex w-fit items-center rounded-md" + end - # Phlex Kit invocation pattern: items call this via the block argument - def ToggleGroupItem(**kwargs, &block) - ctx = item_context - mark_first_item_emitted! - render RubyUI::ToggleGroupItem.new(group_context: ctx, **kwargs), &block + [ + base, + SPACING_GAP[@spacing], + (@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil + ].compact end end end diff --git a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb index bd961ad3..2a654f19 100644 --- a/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb +++ b/gem/lib/ruby_ui/toggle_group/toggle_group_item.rb @@ -2,22 +2,20 @@ module RubyUI class ToggleGroupItem < Toggle + JOIN_BASE = "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10" + def initialize(value:, group_context:, variant: nil, size: nil, **attrs) @item_value = value.to_s @group_context = group_context - effective_variant = variant || group_context[:variant] - effective_size = size || group_context[:size] pressed = group_context[:selected_values].include?(@item_value) - disabled = group_context[:disabled] - super( pressed: pressed, - name: nil, # group owns form serialization + name: nil, value: @item_value, - variant: effective_variant, - size: effective_size, - disabled: disabled, + variant: variant || group_context[:variant], + size: size || group_context[:size], + disabled: group_context[:disabled], **attrs ) end @@ -29,62 +27,41 @@ def view_template(&block) private def default_attrs - type = @group_context[:type] - pressed = @pressed - base_classes_attrs = super + attrs = {type: "button"} + attrs[:disabled] = true if @disabled + attrs[:data] = { + state: @pressed ? "on" : "off", + value: @item_value, + "ruby-ui--toggle-group-target": "item", + action: "click->ruby-ui--toggle-group#select keydown->ruby-ui--toggle-group#navigate" + } + attrs[:class] = [Toggle.classes_for(variant: @variant, size: @size), join_classes] - role_attrs = - if type == :single - { - role: "radio", - aria: {checked: pressed.to_s}, - tabindex: (pressed || @group_context[:roving_first]) ? "0" : "-1" - } - else - { - aria: {pressed: pressed.to_s}, - tabindex: "0" - } - end + if @group_context[:type] == :single + attrs[:role] = "radio" + attrs[:aria] = {checked: @pressed.to_s} + attrs[:tabindex] = @pressed ? "0" : "-1" + else + attrs[:aria] = {pressed: @pressed.to_s} + attrs[:tabindex] = "0" + end + + attrs + end - base_classes_attrs.merge(role_attrs).merge( - data: { - state: pressed ? "on" : "off", - value: @item_value, - "ruby-ui--toggle-group-target": "item", - action: "click->ruby-ui--toggle-group#select keydown->ruby-ui--toggle-group#navigate" - } - ).tap do |h| - # Strip Toggle-primitive's standalone controller wiring — group owns state - h.delete(:controller) if h[:controller] - if h[:data].is_a?(Hash) - h[:data].delete(:controller) if h[:data][:controller] - h[:data].delete(:"ruby-ui--toggle-pressed-value") - h[:data].delete(:"ruby-ui--toggle-value-value") - h[:data].delete(:"ruby-ui--toggle-unpressed-value-value") - end - # For :single, replace aria-pressed (set by parent default_attrs) with aria-checked semantics - if type == :single && h[:aria].is_a?(Hash) - h[:aria].delete(:pressed) - end + def join_classes + classes = [JOIN_BASE] + return classes unless @group_context[:spacing] == 0 - # Append item-level classes for shadcn joined/spaced look - extra = ["w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10"] - spacing = @group_context[:spacing] - orientation = @group_context[:orientation] - variant = @group_context[:variant] - if spacing == 0 - extra << "rounded-none shadow-none" - if orientation == :vertical - extra << "first-of-type:rounded-t-md last-of-type:rounded-b-md" - extra << "border-t-0 first-of-type:border-t" if variant == :outline - else - extra << "first-of-type:rounded-l-md last-of-type:rounded-r-md" - extra << "border-l-0 first-of-type:border-l" if variant == :outline - end - end - h[:class] = [h[:class], *extra].flatten.compact + classes << "rounded-none shadow-none" + if @group_context[:orientation] == :vertical + classes << "first-of-type:rounded-t-md last-of-type:rounded-b-md" + classes << "border-t-0 first-of-type:border-t" if @group_context[:variant] == :outline + else + classes << "first-of-type:rounded-l-md last-of-type:rounded-r-md" + classes << "border-l-0 first-of-type:border-l" if @group_context[:variant] == :outline end + classes end end end diff --git a/gem/test/ruby_ui/toggle_group_test.rb b/gem/test/ruby_ui/toggle_group_test.rb index d7ebfc9f..63439274 100644 --- a/gem/test/ruby_ui/toggle_group_test.rb +++ b/gem/test/ruby_ui/toggle_group_test.rb @@ -33,8 +33,10 @@ def test_single_initial_value_sets_pressed_item g.ToggleGroupItem(value: "right") { "R" } end end - # right item is pressed - assert_match(/data-value="right"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-value="right"/, output) + # right item is pressed — assert both attributes appear (they are on the same button element) + assert_match(/data-value="right"/, output) + assert_match(/aria-checked="true"/, output) + assert_match(/data-state="on"[^>]*data-value="right"|data-value="right"[^>]*data-state="on"/, output) # exactly one hidden input with selected value assert_match(/]*type="hidden"[^>]*name="align"[^>]*value="right"/, output) end From 43741fd0f159c24a605a87c8770053df65b3049c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 20:56:01 -0300 Subject: [PATCH 18/22] [Bug Fix] Update dark_mode getting-started docs to new ThemeToggle API --- .../views/docs/getting_started/dark_mode.rb | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/docs/app/views/docs/getting_started/dark_mode.rb b/docs/app/views/docs/getting_started/dark_mode.rb index 43eb84f3..2abb6e1b 100644 --- a/docs/app/views/docs/getting_started/dark_mode.rb +++ b/docs/app/views/docs/getting_started/dark_mode.rb @@ -48,39 +48,30 @@ def view_template div(class: "pt-4") do render Docs::VisualCodeExample.new(title: "Toggle component", context: self) do <<~RUBY - ThemeToggle do |toggle| - SetLightMode do - Button(variant: :outline, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - d: - "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" - ) - end - end + ThemeToggle do + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-4 h-4 dark:hidden" + ) do |s| + s.path( + d: + "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" + ) end - - SetDarkMode do - Button(variant: :outline, icon: true) do - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "currentColor", - class: "w-4 h-4" - ) do |s| - s.path( - fill_rule: "evenodd", - d: - "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", - clip_rule: "evenodd" - ) - end - end + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-4 h-4 hidden dark:inline-block" + ) do |s| + s.path( + fill_rule: "evenodd", + d: + "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z", + clip_rule: "evenodd" + ) end end RUBY From e510909cc8a97dc791b9e762852b9ee25ce92eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 20:59:08 -0300 Subject: [PATCH 19/22] [Bug Fix] Theme toggle: skip initial dispatch + use setAttribute for double-hyphen data key --- .../controllers/ruby_ui/theme_toggle_controller.js | 2 +- .../app/javascript/controllers/ruby_ui/toggle_controller.js | 6 ++++-- gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js | 2 +- gem/lib/ruby_ui/toggle/toggle_controller.js | 6 ++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js index 3217c37c..ad326526 100644 --- a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js @@ -33,6 +33,6 @@ export default class extends Controller { const dark = theme === "dark" this.element.setAttribute("aria-pressed", dark ? "true" : "false") this.element.dataset.state = dark ? "on" : "off" - this.element.dataset["rubyUi--TogglePressedValue"] = dark ? "true" : "false" + this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false") } } diff --git a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js index 0cacd437..eea1bcf1 100644 --- a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js @@ -14,7 +14,7 @@ export default class extends Controller { this.pressedValue = !this.pressedValue } - pressedValueChanged(current) { + pressedValueChanged(current, previous) { this.element.setAttribute("aria-pressed", current ? "true" : "false") this.element.dataset.state = current ? "on" : "off" @@ -22,6 +22,8 @@ export default class extends Controller { this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue } - this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + if (previous !== undefined) { + this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + } } } diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js index 3217c37c..ad326526 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js @@ -33,6 +33,6 @@ export default class extends Controller { const dark = theme === "dark" this.element.setAttribute("aria-pressed", dark ? "true" : "false") this.element.dataset.state = dark ? "on" : "off" - this.element.dataset["rubyUi--TogglePressedValue"] = dark ? "true" : "false" + this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false") } } diff --git a/gem/lib/ruby_ui/toggle/toggle_controller.js b/gem/lib/ruby_ui/toggle/toggle_controller.js index 0cacd437..eea1bcf1 100644 --- a/gem/lib/ruby_ui/toggle/toggle_controller.js +++ b/gem/lib/ruby_ui/toggle/toggle_controller.js @@ -14,7 +14,7 @@ export default class extends Controller { this.pressedValue = !this.pressedValue } - pressedValueChanged(current) { + pressedValueChanged(current, previous) { this.element.setAttribute("aria-pressed", current ? "true" : "false") this.element.dataset.state = current ? "on" : "off" @@ -22,6 +22,8 @@ export default class extends Controller { this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue } - this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + if (previous !== undefined) { + this.dispatch("change", { detail: { pressed: current }, bubbles: true }) + } } } From 0480f5de67aaf4514e44389d732da652fef2ca68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 21:01:45 -0300 Subject: [PATCH 20/22] [Bug Fix] ThemeToggle action listens for correct event name (Stimulus prefixes with controller identifier) --- .../javascript/controllers/ruby_ui/theme_toggle_controller.js | 2 +- gem/lib/ruby_ui/theme_toggle/theme_toggle.rb | 2 +- gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js | 2 +- gem/test/ruby_ui/theme_toggle_test.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js index ad326526..1912e14e 100644 --- a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--theme-toggle" // Expects to sit on the same element as ruby-ui--toggle and listen to its -// ruby-ui:toggle:change event. pressed = dark mode. +// ruby-ui--toggle:change event. pressed = dark mode. export default class extends Controller { connect() { this.applyTheme(this.currentTheme()) diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb index 1dae08e9..0beaf555 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb @@ -9,7 +9,7 @@ def view_template(&block) aria: {label: "Toggle theme"}, data: { controller: "ruby-ui--toggle ruby-ui--theme-toggle", - action: "ruby-ui:toggle:change->ruby-ui--theme-toggle#apply" + action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply" }, **attrs, &block diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js index ad326526..1912e14e 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--theme-toggle" // Expects to sit on the same element as ruby-ui--toggle and listen to its -// ruby-ui:toggle:change event. pressed = dark mode. +// ruby-ui--toggle:change event. pressed = dark mode. export default class extends Controller { connect() { this.applyTheme(this.currentTheme()) diff --git a/gem/test/ruby_ui/theme_toggle_test.rb b/gem/test/ruby_ui/theme_toggle_test.rb index 05b2a2a9..b81848e9 100644 --- a/gem/test/ruby_ui/theme_toggle_test.rb +++ b/gem/test/ruby_ui/theme_toggle_test.rb @@ -13,7 +13,7 @@ def test_wires_theme_toggle_controller output = phlex { RubyUI.ThemeToggle { "icon" } } assert_match(/data-controller="[^"]*ruby-ui--theme-toggle/, output) assert_match(/data-controller="[^"]*ruby-ui--toggle/, output) - assert_match(/ruby-ui:toggle:change->ruby-ui--theme-toggle#apply/, output) + assert_match(/ruby-ui--toggle:change->ruby-ui--theme-toggle#apply/, output) end def test_block_content_rendered From ddaa88d2bcfb0ce5d53e4d3d3e44b2b45a6a128b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 21:07:05 -0300 Subject: [PATCH 21/22] [Style] ThemeToggle uses default (borderless) variant --- gem/lib/ruby_ui/theme_toggle/theme_toggle.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb index 0beaf555..e27b1b13 100644 --- a/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb +++ b/gem/lib/ruby_ui/theme_toggle/theme_toggle.rb @@ -4,7 +4,7 @@ module RubyUI class ThemeToggle < Base def view_template(&block) RubyUI.Toggle( - variant: :outline, + variant: :default, size: :default, aria: {label: "Toggle theme"}, data: { From 49a1103139aff8de713d5f45057ab4dc4e8e6cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 11 May 2026 21:12:51 -0300 Subject: [PATCH 22/22] [Bug Fix] Wrap Toggle in span so hidden input is a Stimulus target; ThemeToggle uses wrapper kwarg to compose controllers --- .../ruby_ui/theme_toggle_controller.js | 6 ++-- .../controllers/ruby_ui/toggle_controller.js | 12 ++++--- gem/lib/ruby_ui/theme_toggle/theme_toggle.rb | 8 +++-- .../theme_toggle/theme_toggle_controller.js | 6 ++-- gem/lib/ruby_ui/toggle/toggle.rb | 31 ++++++++++++++----- gem/lib/ruby_ui/toggle/toggle_controller.js | 12 ++++--- 6 files changed, 51 insertions(+), 24 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js index 1912e14e..b1eb97f7 100644 --- a/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/theme_toggle_controller.js @@ -1,7 +1,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--theme-toggle" -// Expects to sit on the same element as ruby-ui--toggle and listen to its +// Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's // ruby-ui--toggle:change event. pressed = dark mode. export default class extends Controller { connect() { @@ -30,9 +30,9 @@ export default class extends Controller { html.classList.add("light") html.classList.remove("dark") } + // Flip the sibling Toggle controller's pressed value; it will propagate + // aria-pressed / data-state to the button target. const dark = theme === "dark" - this.element.setAttribute("aria-pressed", dark ? "true" : "false") - this.element.dataset.state = dark ? "on" : "off" this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false") } } diff --git a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js index eea1bcf1..25948876 100644 --- a/docs/app/javascript/controllers/ruby_ui/toggle_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toggle_controller.js @@ -1,8 +1,10 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--toggle" +// Sits on a wrapper element; the visible