Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0209f54
[Feature] Add Toggle component (button + aria-pressed + hidden input)
djalmaaraujo May 11, 2026
51c89e3
[Feature] Toggle Stimulus controller
djalmaaraujo May 11, 2026
dc83bff
[Feature] Register toggle/toggle_group/theme_toggle component depende…
djalmaaraujo May 11, 2026
23d5a16
[Feature] Add ToggleGroup + ToggleGroupItem (single/multiple, ARIA, f…
djalmaaraujo May 11, 2026
15e141d
[Feature] ToggleGroup Stimulus controller (single/multiple, roving ta…
djalmaaraujo May 11, 2026
5ed1d70
[Refactor] ThemeToggle composes Toggle; drop SetDarkMode/SetLightMode
djalmaaraujo May 11, 2026
97222b2
[Documentation] Add Toggle docs page
djalmaaraujo May 11, 2026
2c986f9
[Documentation] Add ToggleGroup docs page
djalmaaraujo May 11, 2026
80d5ea7
[Documentation] Regenerate site files for toggle pages
djalmaaraujo May 11, 2026
17c4ccd
[Bug Fix] Update docs navbar + theme_toggle view to new ThemeToggle API
djalmaaraujo May 11, 2026
f66e549
[Bug Fix] Render Toggle/ToggleGroup views from docs controller actions
djalmaaraujo May 11, 2026
0191ed0
[Documentation] Ignore specs/ planning directory
djalmaaraujo May 11, 2026
388bec3
[Bug Fix] Register Toggle/ToggleGroup Stimulus controllers in docs app
djalmaaraujo May 11, 2026
7d2e27f
[Feature] ToggleGroup match shadcn look (joined default, spacing, ori…
djalmaaraujo May 11, 2026
e02135c
[Bug Fix] ToggleGroup joined corners use first-of-type/last-of-type; …
djalmaaraujo May 11, 2026
47836a0
[Documentation] Add Multiple selection example to Toggle Group docs
djalmaaraujo May 11, 2026
1343f31
[Refactor] Simplify Toggle/ToggleGroup: extract class builders, drop …
djalmaaraujo May 11, 2026
43741fd
[Bug Fix] Update dark_mode getting-started docs to new ThemeToggle API
djalmaaraujo May 11, 2026
e510909
[Bug Fix] Theme toggle: skip initial dispatch + use setAttribute for …
djalmaaraujo May 11, 2026
0480f5d
[Bug Fix] ThemeToggle action listens for correct event name (Stimulus…
djalmaaraujo May 12, 2026
ddaa88d
[Style] ThemeToggle uses default (borderless) variant
djalmaaraujo May 12, 2026
49a1103
[Bug Fix] Wrap Toggle in span so hidden input is a Stimulus target; T…
djalmaaraujo May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
CLAUDE.local.md
specs/
2 changes: 2 additions & 0 deletions docs/app/components/shared/components_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}
]
Expand Down
52 changes: 22 additions & 30 deletions docs/app/components/shared/navbar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/app/controllers/docs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ def theme_toggle
render Views::Docs::ThemeToggle.new
end

def toggle
render Views::Docs::Toggle.new
end

def toggle_group
render Views::Docs::ToggleGroup.new
end

def tooltip
render Views::Docs::Tooltip.new
end
Expand Down
12 changes: 9 additions & 3 deletions docs/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="ruby-ui--theme-toggle"
// 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 {
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")
}
// 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("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false")
}
}
33 changes: 33 additions & 0 deletions docs/app/javascript/controllers/ruby_ui/toggle_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="ruby-ui--toggle"
// Sits on a wrapper element; the visible <button> and optional hidden <input>
// are descendants so Stimulus can target them.
export default class extends Controller {
static targets = ["button", "input"]
static values = {
pressed: Boolean,
value: String,
unpressedValue: String
}

toggle(event) {
if (this.buttonTarget.disabled) return
this.pressedValue = !this.pressedValue
}

pressedValueChanged(current, previous) {
if (this.hasButtonTarget) {
this.buttonTarget.setAttribute("aria-pressed", current ? "true" : "false")
this.buttonTarget.dataset.state = current ? "on" : "off"
}

if (this.hasInputTarget) {
this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue
}

if (previous !== undefined) {
this.dispatch("change", { detail: { pressed: current }, bubbles: true })
}
}
}
126 changes: 126 additions & 0 deletions docs/app/javascript/controllers/ruby_ui/toggle_group_controller.js
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 2 additions & 0 deletions docs/app/lib/site_files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading