Skip to content

Commit 62fff5d

Browse files
authored
[Render Preview] [Feature] Toggle + ToggleGroup; refactor ThemeToggle (#392)
* [Feature] Add Toggle component (button + aria-pressed + hidden input) * [Feature] Toggle Stimulus controller * [Feature] Register toggle/toggle_group/theme_toggle component dependencies * [Feature] Add ToggleGroup + ToggleGroupItem (single/multiple, ARIA, form binding) * [Feature] ToggleGroup Stimulus controller (single/multiple, roving tabindex, form sync) * [Refactor] ThemeToggle composes Toggle; drop SetDarkMode/SetLightMode * [Documentation] Add Toggle docs page * [Documentation] Add ToggleGroup docs page * [Documentation] Regenerate site files for toggle pages * [Bug Fix] Update docs navbar + theme_toggle view to new ThemeToggle API * [Bug Fix] Render Toggle/ToggleGroup views from docs controller actions * [Documentation] Ignore specs/ planning directory * [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. * [Feature] ToggleGroup match shadcn look (joined default, spacing, orientation) * [Bug Fix] ToggleGroup joined corners use first-of-type/last-of-type; align docs examples with shadcn * [Documentation] Add Multiple selection example to Toggle Group docs * [Refactor] Simplify Toggle/ToggleGroup: extract class builders, drop indirections + roving state * [Bug Fix] Update dark_mode getting-started docs to new ThemeToggle API * [Bug Fix] Theme toggle: skip initial dispatch + use setAttribute for double-hyphen data key * [Bug Fix] ThemeToggle action listens for correct event name (Stimulus prefixes with controller identifier) * [Style] ThemeToggle uses default (borderless) variant * [Bug Fix] Wrap Toggle in span so hidden input is a Stimulus target; ThemeToggle uses wrapper kwarg to compose controllers
1 parent abfd069 commit 62fff5d

31 files changed

Lines changed: 1234 additions & 240 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
CLAUDE.local.md
2+
specs/

docs/app/components/shared/components_list.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def components
5151
{name: "Textarea", path: docs_textarea_path},
5252
{name: "Theme Toggle", path: docs_theme_toggle_path},
5353
{name: "Toast", path: docs_toast_path},
54+
{name: "Toggle", path: docs_toggle_path},
55+
{name: "Toggle Group", path: docs_toggle_group_path},
5456
{name: "Tooltip", path: docs_tooltip_path},
5557
{name: "Typography", path: docs_typography_path}
5658
]

docs/app/components/shared/navbar.rb

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,37 +34,29 @@ def view_template
3434

3535
def dark_mode_toggle
3636
ThemeToggle do
37-
SetLightMode do
38-
Button(variant: :ghost, icon: true) do
39-
svg(
40-
xmlns: "http://www.w3.org/2000/svg",
41-
viewbox: "0 0 24 24",
42-
fill: "currentColor",
43-
class: "w-5 h-5"
44-
) do |s|
45-
s.path(
46-
d:
47-
"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"
48-
)
49-
end
50-
end
37+
# Sun (visible in light mode)
38+
svg(
39+
xmlns: "http://www.w3.org/2000/svg",
40+
viewbox: "0 0 24 24",
41+
fill: "currentColor",
42+
class: "w-5 h-5 dark:hidden"
43+
) do |s|
44+
s.path(
45+
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"
46+
)
5147
end
52-
SetDarkMode do
53-
Button(variant: :ghost, icon: true) do
54-
svg(
55-
xmlns: "http://www.w3.org/2000/svg",
56-
viewbox: "0 0 24 24",
57-
fill: "currentColor",
58-
class: "w-4 h-4"
59-
) do |s|
60-
s.path(
61-
fill_rule: "evenodd",
62-
d:
63-
"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",
64-
clip_rule: "evenodd"
65-
)
66-
end
67-
end
48+
# Moon (visible in dark mode)
49+
svg(
50+
xmlns: "http://www.w3.org/2000/svg",
51+
viewbox: "0 0 24 24",
52+
fill: "currentColor",
53+
class: "w-5 h-5 hidden dark:inline-block"
54+
) do |s|
55+
s.path(
56+
fill_rule: "evenodd",
57+
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",
58+
clip_rule: "evenodd"
59+
)
6860
end
6961
end
7062
end

docs/app/controllers/docs_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ def toast
230230
render Views::Docs::Toast.new
231231
end
232232

233+
def toggle
234+
render Views::Docs::Toggle.new
235+
end
236+
237+
def toggle_group
238+
render Views::Docs::ToggleGroup.new
239+
end
240+
233241
def tooltip
234242
render Views::Docs::Tooltip.new
235243
end

docs/app/javascript/controllers/index.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@ application.register("ruby-ui--command-dialog", RubyUi__CommandDialogController)
5252
import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller"
5353
application.register("ruby-ui--context-menu", RubyUi__ContextMenuController)
5454

55-
import RubyUi__DataTableController from "./ruby_ui/data_table_controller"
56-
application.register("ruby-ui--data-table", RubyUi__DataTableController)
57-
5855
import RubyUi__DataTableColumnVisibilityController from "./ruby_ui/data_table_column_visibility_controller"
5956
application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableColumnVisibilityController)
6057

58+
import RubyUi__DataTableController from "./ruby_ui/data_table_controller"
59+
application.register("ruby-ui--data-table", RubyUi__DataTableController)
60+
6161
import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller"
6262
application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController)
6363

@@ -106,6 +106,12 @@ application.register("ruby-ui--toast", RubyUi__ToastController)
106106
import RubyUi__ToasterController from "./ruby_ui/toaster_controller"
107107
application.register("ruby-ui--toaster", RubyUi__ToasterController)
108108

109+
import RubyUi__ToggleController from "./ruby_ui/toggle_controller"
110+
application.register("ruby-ui--toggle", RubyUi__ToggleController)
111+
112+
import RubyUi__ToggleGroupController from "./ruby_ui/toggle_group_controller"
113+
application.register("ruby-ui--toggle-group", RubyUi__ToggleGroupController)
114+
109115
import RubyUi__TooltipController from "./ruby_ui/tooltip_controller"
110116
application.register("ruby-ui--tooltip", RubyUi__TooltipController)
111117

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,38 @@
11
import { Controller } from "@hotwired/stimulus"
22

3+
// Connects to data-controller="ruby-ui--theme-toggle"
4+
// Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's
5+
// ruby-ui--toggle:change event. pressed = dark mode.
36
export default class extends Controller {
4-
initialize() {
5-
this.setTheme()
7+
connect() {
8+
this.applyTheme(this.currentTheme())
69
}
710

8-
setTheme() {
9-
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
10-
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
11-
document.documentElement.classList.add('dark')
12-
document.documentElement.classList.remove('light')
13-
} else {
14-
document.documentElement.classList.remove('dark')
15-
document.documentElement.classList.add('light')
16-
}
11+
apply(event) {
12+
const pressed = event.detail?.pressed
13+
const theme = pressed ? "dark" : "light"
14+
localStorage.theme = theme
15+
this.applyTheme(theme)
1716
}
1817

19-
setLightTheme() {
20-
// Whenever the user explicitly chooses light mode
21-
localStorage.theme = 'light'
22-
this.setTheme()
18+
currentTheme() {
19+
if (localStorage.theme === "dark") return "dark"
20+
if (localStorage.theme === "light") return "light"
21+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
2322
}
2423

25-
setDarkTheme() {
26-
// Whenever the user explicitly chooses dark mode
27-
localStorage.theme = 'dark'
28-
this.setTheme()
24+
applyTheme(theme) {
25+
const html = document.documentElement
26+
if (theme === "dark") {
27+
html.classList.add("dark")
28+
html.classList.remove("light")
29+
} else {
30+
html.classList.add("light")
31+
html.classList.remove("dark")
32+
}
33+
// Flip the sibling Toggle controller's pressed value; it will propagate
34+
// aria-pressed / data-state to the button target.
35+
const dark = theme === "dark"
36+
this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false")
2937
}
3038
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="ruby-ui--toggle"
4+
// Sits on a wrapper element; the visible <button> and optional hidden <input>
5+
// are descendants so Stimulus can target them.
6+
export default class extends Controller {
7+
static targets = ["button", "input"]
8+
static values = {
9+
pressed: Boolean,
10+
value: String,
11+
unpressedValue: String
12+
}
13+
14+
toggle(event) {
15+
if (this.buttonTarget.disabled) return
16+
this.pressedValue = !this.pressedValue
17+
}
18+
19+
pressedValueChanged(current, previous) {
20+
if (this.hasButtonTarget) {
21+
this.buttonTarget.setAttribute("aria-pressed", current ? "true" : "false")
22+
this.buttonTarget.dataset.state = current ? "on" : "off"
23+
}
24+
25+
if (this.hasInputTarget) {
26+
this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue
27+
}
28+
29+
if (previous !== undefined) {
30+
this.dispatch("change", { detail: { pressed: current }, bubbles: true })
31+
}
32+
}
33+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="ruby-ui--toggle-group"
4+
export default class extends Controller {
5+
static targets = ["item", "input"]
6+
static values = { type: String, name: String }
7+
8+
connect() {
9+
this.reconcile()
10+
}
11+
12+
select(event) {
13+
const item = event.currentTarget
14+
if (item.disabled) return
15+
16+
if (this.typeValue === "single") {
17+
this.itemTargets.forEach(el => this.setPressed(el, el === item))
18+
} else {
19+
this.setPressed(item, !this.isPressed(item))
20+
}
21+
22+
this.rebuildInputs()
23+
this.updateRovingTabindex(item)
24+
}
25+
26+
navigate(event) {
27+
if (this.typeValue !== "single") return
28+
const items = this.enabledItems()
29+
if (items.length === 0) return
30+
31+
const isRtl = document.documentElement.dir === "rtl"
32+
const currentIndex = items.indexOf(event.currentTarget)
33+
let nextIndex = currentIndex
34+
35+
switch (event.key) {
36+
case "ArrowRight":
37+
case "ArrowDown":
38+
nextIndex = (currentIndex + (isRtl && event.key === "ArrowRight" ? -1 : 1) + items.length) % items.length
39+
break
40+
case "ArrowLeft":
41+
case "ArrowUp":
42+
nextIndex = (currentIndex + (isRtl && event.key === "ArrowLeft" ? 1 : -1) + items.length) % items.length
43+
break
44+
case "Home":
45+
nextIndex = 0
46+
break
47+
case "End":
48+
nextIndex = items.length - 1
49+
break
50+
case " ":
51+
case "Enter":
52+
event.preventDefault()
53+
event.currentTarget.click()
54+
return
55+
default:
56+
return
57+
}
58+
59+
event.preventDefault()
60+
const next = items[nextIndex]
61+
this.updateRovingTabindex(next)
62+
next.focus()
63+
}
64+
65+
reconcile() {
66+
if (this.typeValue === "single") {
67+
const pressed = this.itemTargets.find(el => this.isPressed(el))
68+
const first = pressed || this.enabledItems()[0]
69+
this.itemTargets.forEach(el => {
70+
el.setAttribute("tabindex", el === first ? "0" : "-1")
71+
})
72+
} else {
73+
this.itemTargets.forEach(el => el.setAttribute("tabindex", "0"))
74+
}
75+
this.rebuildInputs()
76+
}
77+
78+
isPressed(item) {
79+
return item.dataset.state === "on"
80+
}
81+
82+
setPressed(item, pressed) {
83+
item.dataset.state = pressed ? "on" : "off"
84+
if (this.typeValue === "single") {
85+
item.setAttribute("aria-checked", pressed ? "true" : "false")
86+
} else {
87+
item.setAttribute("aria-pressed", pressed ? "true" : "false")
88+
}
89+
}
90+
91+
updateRovingTabindex(focusedItem) {
92+
if (this.typeValue !== "single") return
93+
this.itemTargets.forEach(el => {
94+
el.setAttribute("tabindex", el === focusedItem ? "0" : "-1")
95+
})
96+
}
97+
98+
enabledItems() {
99+
return this.itemTargets.filter(el => !el.disabled)
100+
}
101+
102+
rebuildInputs() {
103+
if (!this.nameValue) return
104+
this.inputTargets.forEach(el => el.remove())
105+
106+
const pressed = this.itemTargets.filter(el => this.isPressed(el))
107+
108+
if (this.typeValue === "single") {
109+
const val = pressed[0]?.dataset.value || ""
110+
this.element.appendChild(this.buildInput(this.nameValue, val))
111+
} else {
112+
pressed.forEach(item => {
113+
this.element.appendChild(this.buildInput(`${this.nameValue}[]`, item.dataset.value))
114+
})
115+
}
116+
}
117+
118+
buildInput(name, value) {
119+
const input = document.createElement("input")
120+
input.type = "hidden"
121+
input.name = name
122+
input.value = value
123+
input.setAttribute("data-ruby-ui--toggle-group-target", "input")
124+
return input
125+
}
126+
}

docs/app/lib/site_files.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ class SiteFiles
123123
{title: "Tabs", path: "/docs/tabs", description: "Layered tab panels displayed one at a time."},
124124
{title: "Textarea", path: "/docs/textarea", description: "Styled multiline text input."},
125125
{title: "Theme Toggle", path: "/docs/theme_toggle", description: "Toggle control for switching between light and dark themes."},
126+
{title: "Toggle", path: "/docs/toggle", description: "Two-state button that can be either on or off."},
127+
{title: "Toggle Group", path: "/docs/toggle_group", description: "Group of two-state toggle buttons."},
126128
{title: "Tooltip", path: "/docs/tooltip", description: "Popup information shown on keyboard focus or hover."},
127129
{title: "Typography", path: "/docs/typography", description: "Text primitives and sensible typography defaults."}
128130
].freeze

0 commit comments

Comments
 (0)