Skip to content
This repository was archived by the owner on May 11, 2026. It is now read-only.

Commit 0c5b66b

Browse files
add nested sidebar
1 parent f68be0e commit 0c5b66b

6 files changed

Lines changed: 281 additions & 0 deletions

File tree

app/controllers/docs/sidebar_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,10 @@ def inset_example
1414

1515
render Views::Docs::Sidebar::InsetExample.new(sidebar_state:)
1616
end
17+
18+
def nested_example
19+
sidebar_state = cookies.fetch(:sidebar_state, "true") == "true"
20+
21+
render Views::Docs::Sidebar::NestedExample.new(sidebar_state:)
22+
end
1723
end

app/javascript/controllers/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController)
8787

8888
import SidebarMenuController from "./sidebar_menu_controller"
8989
application.register("sidebar-menu", SidebarMenuController)
90+
91+
import NestedSidebarController from "./nested_sidebar_controller"
92+
application.register("nested-sidebar", NestedSidebarController)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="nested-sidebar"
4+
// Switches content panels when icon rail buttons are clicked
5+
export default class extends Controller {
6+
static targets = ["icon", "panel"]
7+
static values = { active: String }
8+
9+
select(event) {
10+
this.activeValue = event.currentTarget.dataset.section
11+
}
12+
13+
activeValueChanged() {
14+
this.iconTargets.forEach((icon) => {
15+
const isActive = icon.dataset.section === this.activeValue
16+
icon.dataset.active = isActive
17+
})
18+
19+
this.panelTargets.forEach((panel) => {
20+
panel.hidden = panel.dataset.section !== this.activeValue
21+
})
22+
}
23+
}

app/views/docs/sidebar.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def view_template
3737
Views::Docs::Sidebar::InsetExample::CODE
3838
end
3939

40+
render Docs::VisualCodeExample.new(title: "Nested sidebar", src: "/docs/sidebar/nested", context: self) do
41+
Views::Docs::Sidebar::NestedExample::CODE
42+
end
43+
4044
render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do
4145
<<~RUBY
4246
Dialog(data: {action: "ruby-ui--dialog:connect->ruby-ui--dialog#open"}) do
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# frozen_string_literal: true
2+
3+
class Views::Docs::Sidebar::NestedExample < Views::Base
4+
FAVORITES = [
5+
{name: "Project Management & Task Tracking", emoji: "\u{1F4CA}"},
6+
{name: "Movies & TV Shows", emoji: "\u{1F3AC}"},
7+
{name: "Books & Articles", emoji: "\u{1F4DA}"},
8+
{name: "Recipes & Meal Planning", emoji: "\u{1F37D}\u{FE0F}"},
9+
{name: "Travel & Places", emoji: "\u{1F30D}"},
10+
{name: "Health & Fitness", emoji: "\u{1F3CB}\u{FE0F}"}
11+
].freeze
12+
13+
WORKSPACES = [
14+
{name: "Personal Life Management", emoji: "\u{1F3E1}"},
15+
{name: "Work & Projects", emoji: "\u{1F4BC}"},
16+
{name: "Side Projects", emoji: "\u{1F680}"},
17+
{name: "Learning & Courses", emoji: "\u{1F4DA}"},
18+
{name: "Writing & Blogging", emoji: "\u{270D}\u{FE0F}"},
19+
{name: "Design & Development", emoji: "\u{1F3A8}"}
20+
].freeze
21+
22+
def initialize(sidebar_state:)
23+
@sidebar_state = sidebar_state
24+
end
25+
26+
CODE = <<~RUBY
27+
SidebarWrapper do
28+
Sidebar(collapsible: :icon, variant: :inset) do
29+
div(class: "flex h-full", data: {controller: "nested-sidebar", nested_sidebar_active_value: "home"}) do
30+
# Icon rail
31+
div(class: "flex flex-col items-center w-12 shrink-0 border-r border-sidebar-border py-4") do
32+
div(class: "flex flex-col items-center gap-1") do
33+
icon_rail_button("home", active: true) { home_icon() }
34+
icon_rail_button("favorites") { star_icon() }
35+
icon_rail_button("workspaces") { briefcase_icon() }
36+
icon_rail_button("settings") { settings_icon() }
37+
end
38+
div(class: "mt-auto") do
39+
DropdownMenu(options: {strategy: "fixed", placement: "right-end"}) do
40+
button(
41+
type: "button",
42+
class: "flex items-center justify-center size-8 shrink-0 rounded-full bg-sidebar-accent text-sidebar-accent-foreground font-semibold text-sm",
43+
data: {
44+
ruby_ui__dropdown_menu_target: "trigger",
45+
action: "click->ruby-ui--dropdown-menu#toggle"
46+
}
47+
) { "JD" }
48+
DropdownMenuContent(class: "min-w-56 rounded-lg z-50") do
49+
DropdownMenuLabel do
50+
div(class: "flex flex-col") do
51+
span(class: "truncate font-semibold") { "John Doe" }
52+
span(class: "truncate text-xs text-muted-foreground") { "john@example.com" }
53+
end
54+
end
55+
DropdownMenuSeparator()
56+
DropdownMenuItem(href: "#") { "Profile" }
57+
DropdownMenuItem(href: "#") { "Billing" }
58+
DropdownMenuItem(href: "#") { "Settings" }
59+
DropdownMenuSeparator()
60+
DropdownMenuItem(href: "#") { "Sign out" }
61+
end
62+
end
63+
end
64+
end
65+
66+
# Content panels (one per icon, toggled by nested-sidebar controller)
67+
div(class: "flex-1 flex flex-col min-w-0 group-data-[collapsible=icon]:hidden") do
68+
# Home panel
69+
div(data: {nested_sidebar_target: "panel", section: "home"}) do
70+
SidebarContent do
71+
SidebarGroup do
72+
SidebarMenu do
73+
SidebarMenuItem do
74+
SidebarMenuButton(as: :a, href: "#") do
75+
search_icon()
76+
span { "Search" }
77+
end
78+
end
79+
SidebarMenuItem do
80+
SidebarMenuButton(as: :a, href: "#", active: true) do
81+
home_icon()
82+
span { "Home" }
83+
end
84+
end
85+
SidebarMenuItem do
86+
SidebarMenuButton(as: :a, href: "#") do
87+
inbox_icon()
88+
span { "Inbox" }
89+
SidebarMenuBadge { 4 }
90+
end
91+
end
92+
end
93+
end
94+
end
95+
end
96+
97+
# Favorites panel
98+
div(hidden: true, data: {nested_sidebar_target: "panel", section: "favorites"}) do
99+
SidebarContent do
100+
SidebarGroup do
101+
SidebarGroupLabel { "Favorites" }
102+
SidebarMenu do
103+
FAVORITES.each do |item|
104+
SidebarMenuItem do
105+
SidebarMenuButton(as: :a, href: "#") do
106+
span { item[:emoji] }
107+
span { item[:name] }
108+
end
109+
end
110+
end
111+
end
112+
end
113+
end
114+
end
115+
116+
# Workspaces panel
117+
div(hidden: true, data: {nested_sidebar_target: "panel", section: "workspaces"}) do
118+
SidebarContent do
119+
SidebarGroup do
120+
SidebarGroupLabel { "Workspaces" }
121+
SidebarMenu do
122+
WORKSPACES.each do |item|
123+
SidebarMenuItem do
124+
SidebarMenuButton(as: :a, href: "#") do
125+
span { item[:emoji] }
126+
span { item[:name] }
127+
end
128+
end
129+
end
130+
end
131+
end
132+
end
133+
end
134+
135+
# Settings panel
136+
div(hidden: true, data: {nested_sidebar_target: "panel", section: "settings"}) do
137+
SidebarContent do
138+
SidebarGroup do
139+
SidebarGroupContent do
140+
SidebarMenu do
141+
SidebarMenuItem do
142+
SidebarMenuButton(as: :a, href: "#") do
143+
settings_icon()
144+
span { "Settings" }
145+
end
146+
end
147+
SidebarMenuItem do
148+
SidebarMenuButton(as: :a, href: "#") do
149+
message_circle_question_icon()
150+
span { "Help & Support" }
151+
end
152+
end
153+
end
154+
end
155+
end
156+
end
157+
end
158+
end
159+
end
160+
SidebarRail()
161+
end
162+
SidebarInset do
163+
header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do
164+
SidebarTrigger(class: "-ml-1")
165+
end
166+
end
167+
end
168+
RUBY
169+
170+
def view_template
171+
decoded_code = CGI.unescapeHTML(CODE)
172+
instance_eval(decoded_code)
173+
end
174+
175+
private
176+
177+
def icon_rail_button(section, active: false, &block)
178+
classes = [
179+
"flex items-center justify-center size-8 rounded-md transition-colors [&>svg]:size-4 [&>svg]:shrink-0",
180+
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
181+
"data-[active=false]:text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
182+
].join(" ")
183+
button(
184+
type: "button",
185+
class: classes,
186+
data: {
187+
nested_sidebar_target: "icon",
188+
section: section,
189+
action: "click->nested-sidebar#select",
190+
active: active
191+
},
192+
&block
193+
)
194+
end
195+
196+
def home_icon
197+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
198+
s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8")
199+
s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z")
200+
end
201+
end
202+
203+
def star_icon
204+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
205+
s.path(d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a.53.53 0 0 0 .4.29l5.16.756a.53.53 0 0 1 .294.904l-3.733 3.638a.53.53 0 0 0-.153.469l.882 5.14a.53.53 0 0 1-.77.56l-4.614-2.426a.53.53 0 0 0-.494 0L7.14 18.73a.53.53 0 0 1-.77-.56l.882-5.14a.53.53 0 0 0-.153-.47L3.365 8.925a.53.53 0 0 1 .294-.904l5.16-.756a.53.53 0 0 0 .4-.29z")
206+
end
207+
end
208+
209+
def briefcase_icon
210+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
211+
s.path(d: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16")
212+
s.rect(width: "20", height: "14", x: "2", y: "6", rx: "2")
213+
end
214+
end
215+
216+
def search_icon
217+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
218+
s.circle(cx: "11", cy: "11", r: "8")
219+
s.path(d: "M21 21L16.7 16.7")
220+
end
221+
end
222+
223+
def inbox_icon
224+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
225+
s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12")
226+
s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z")
227+
end
228+
end
229+
230+
def settings_icon
231+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
232+
s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z")
233+
s.circle(cx: "12", cy: "12", r: "3")
234+
end
235+
end
236+
237+
def message_circle_question_icon
238+
svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
239+
s.path(d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z")
240+
s.path(d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3")
241+
s.path(d: "M12 17h.01")
242+
end
243+
end
244+
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
get "sidebar", to: "docs#sidebar", as: :docs_sidebar
5454
get "sidebar/example", to: "docs/sidebar#example", as: :docs_sidebar_example
5555
get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset
56+
get "sidebar/nested", to: "docs/sidebar#nested_example", as: :docs_sidebar_nested
5657
get "skeleton", to: "docs#skeleton", as: :docs_skeleton
5758
get "switch", to: "docs#switch", as: :docs_switch
5859
get "table", to: "docs#table", as: :docs_table

0 commit comments

Comments
 (0)