Coverage for src/lexigram/admin/ui/layouts/components/sidebar.py: 27%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1"""Sidebar navigation component for admin layout. 

2 

3Renders the sidebar with navigation items, groups, and collapse functionality. 

4""" 

5 

6from __future__ import annotations 

7 

8from dataclasses import dataclass, field 

9from typing import Any 

10 

11from markupsafe import escape 

12 

13 

14@dataclass 

15class NavItem: 

16 """Single navigation item.""" 

17 

18 label: str 

19 url: str 

20 icon: str = "circle" 

21 badge: str | None = None 

22 badge_color: str = "blue" 

23 is_active: bool = False 

24 is_disabled: bool = False 

25 target: str = "_self" 

26 hx_attrs: dict[str, str] = field(default_factory=dict) 

27 children: list[NavItem] = field(default_factory=list) 

28 

29 

30@dataclass 

31class NavGroup: 

32 """Navigation group with optional header.""" 

33 

34 label: str | None = None 

35 items: list[NavItem] = field(default_factory=list) 

36 is_collapsible: bool = False 

37 is_collapsed: bool = False 

38 

39 

40@dataclass 

41class SidebarConfig: 

42 """Configuration for sidebar.""" 

43 

44 # Display 

45 width: str = "260px" 

46 collapsed_width: str = "64px" 

47 is_collapsible: bool = True 

48 default_collapsed: bool = False 

49 

50 # Features 

51 show_footer: bool = True 

52 footer_text: str = "" 

53 

54 # Styling 

55 bg_class: str = "bg-background" 

56 text_class: str = "text-foreground" 

57 

58 # Branding (optional, if not in header) 

59 show_logo: bool = False 

60 logo_url: str | None = None 

61 site_name: str = "Admin" 

62 

63 

64class SidebarRenderer: 

65 """Renders the sidebar navigation.""" 

66 

67 def __init__( 

68 self, 

69 config: SidebarConfig | None = None, 

70 groups: list[NavGroup] | None = None, 

71 ): 

72 """Initialize the renderer. 

73 

74 Args: 

75 config: Sidebar configuration 

76 groups: Navigation groups 

77 """ 

78 self.config = config or SidebarConfig() 

79 self.groups = groups or [] 

80 

81 def render(self, current_path: str = "/") -> str: 

82 """Render the sidebar. 

83 

84 Args: 

85 current_path: Current request path for highlighting active item 

86 

87 Returns: 

88 HTML string for sidebar 

89 """ 

90 parts: list[str] = [] 

91 

92 collapsed_class = "sidebar-collapsed" if self.config.default_collapsed else "" 

93 

94 parts.append( 

95 f'<aside class="admin-sidebar {collapsed_class}" id="admin-sidebar">', 

96 ) 

97 

98 # Mobile overlay 

99 parts.append( 

100 '<div class="sidebar-overlay lg:hidden" onclick="closeSidebar()"></div>', 

101 ) 

102 

103 # Sidebar content wrapper 

104 parts.append('<div class="sidebar-content">') 

105 

106 # Optional logo section 

107 if self.config.show_logo: 

108 parts.append(self._render_logo()) 

109 

110 # Navigation 

111 parts.append('<nav class="sidebar-nav">') 

112 for group in self.groups: 

113 parts.append(self._render_nav_group(group, current_path)) 

114 parts.append("</nav>") 

115 

116 # Collapse toggle 

117 if self.config.is_collapsible: 

118 parts.append(self._render_collapse_toggle()) 

119 

120 # Footer 

121 if self.config.show_footer and self.config.footer_text: 

122 parts.append(self._render_footer()) 

123 

124 parts.append("</div>") # sidebar-content 

125 parts.append("</aside>") 

126 

127 return "\n".join(parts) 

128 

129 def _render_logo(self) -> str: 

130 """Render sidebar logo section.""" 

131 parts: list[str] = [] 

132 

133 parts.append('<div class="sidebar-logo">') 

134 

135 if self.config.logo_url: 

136 parts.append( 

137 f'<img src="{escape(self.config.logo_url)}" alt="{escape(self.config.site_name)}" class="sidebar-logo-img">', 

138 ) 

139 else: 

140 parts.append( 

141 f'<span class="sidebar-logo-text">{escape(self.config.site_name)}</span>', 

142 ) 

143 

144 parts.append("</div>") 

145 

146 return "\n".join(parts) 

147 

148 def _render_nav_group(self, group: NavGroup, current_path: str) -> str: 

149 """Render a navigation group.""" 

150 parts: list[str] = [] 

151 

152 collapsed_class = "is-collapsed" if group.is_collapsed else "" 

153 parts.append(f'<div class="nav-group {collapsed_class}">') 

154 

155 # Group header 

156 if group.label: 

157 if group.is_collapsible: 

158 parts.append(f""" 

159 <button type="button" class="nav-group-header nav-group-toggle" 

160 onclick="this.parentElement.classList.toggle('is-collapsed')"> 

161 <span>{escape(group.label)}</span> 

162 <i data-lucide="chevron-down" class="nav-group-arrow w-4 h-4"></i> 

163 </button> 

164 """) 

165 else: 

166 parts.append( 

167 f'<div class="nav-group-header">{escape(group.label)}</div>', 

168 ) 

169 

170 # Group items 

171 parts.append('<ul class="nav-group-items">') 

172 for item in group.items: 

173 parts.append(self._render_nav_item(item, current_path)) 

174 parts.append("</ul>") 

175 

176 parts.append("</div>") 

177 

178 return "\n".join(parts) 

179 

180 def _render_nav_item(self, item: NavItem, current_path: str, level: int = 0) -> str: 

181 """Render a single navigation item.""" 

182 # Check if active 

183 is_active = ( 

184 item.is_active 

185 or current_path == item.url 

186 or current_path.startswith(item.url + "/") 

187 ) 

188 

189 # Check if has active child 

190 has_active_child = any( 

191 current_path == child.url or current_path.startswith(child.url + "/") 

192 for child in item.children 

193 ) 

194 

195 active_class = "is-active" if is_active else "" 

196 disabled_class = "is-disabled" if item.is_disabled else "" 

197 has_children_class = "has-children" if item.children else "" 

198 expanded_class = "is-expanded" if has_active_child else "" 

199 level_class = f"nav-level-{level}" 

200 

201 # Build hx-* attributes 

202 hx_attrs_str = "" 

203 for key, value in item.hx_attrs.items(): 

204 hx_attrs_str += f' hx-{escape(key)}="{escape(value)}"' 

205 

206 # Badge 

207 badge = "" 

208 if item.badge: 

209 badge = f'<span class="nav-badge badge-{escape(item.badge_color)}">{escape(item.badge)}</span>' 

210 

211 parts: list[str] = [] 

212 parts.append( 

213 f'<li class="nav-item {active_class} {disabled_class} {has_children_class} {expanded_class} {level_class}">', 

214 ) 

215 

216 if item.children: 

217 # Has submenu 

218 parts.append(f""" 

219 <button type="button" class="nav-link nav-link-toggle" 

220 onclick="this.parentElement.classList.toggle('is-expanded')"> 

221 <i data-lucide="{escape(item.icon)}" class="nav-icon w-5 h-5"></i> 

222 <span class="nav-label">{escape(item.label)}</span> 

223 {badge} 

224 <i data-lucide="chevron-right" class="nav-arrow w-4 h-4"></i> 

225 </button> 

226 """) 

227 

228 # Submenu 

229 parts.append('<ul class="nav-submenu">') 

230 for child in item.children: 

231 parts.append(self._render_nav_item(child, current_path, level + 1)) 

232 parts.append("</ul>") 

233 else: 

234 # Regular link 

235 parts.append(f""" 

236 <a href="{escape(item.url)}" 

237 class="nav-link" 

238 target="{escape(item.target)}" 

239 {hx_attrs_str}> 

240 <i data-lucide="{escape(item.icon)}" class="nav-icon w-5 h-5"></i> 

241 <span class="nav-label">{escape(item.label)}</span> 

242 {badge} 

243 </a> 

244 """) 

245 

246 parts.append("</li>") 

247 

248 return "\n".join(parts) 

249 

250 def _render_collapse_toggle(self) -> str: 

251 """Render sidebar collapse toggle button.""" 

252 return """ 

253 <button type="button" class="sidebar-collapse-btn" 

254 onclick="toggleSidebar()" 

255 title="Toggle sidebar"> 

256 <i data-lucide="panel-left-close" class="w-5 h-5 sidebar-expanded-icon"></i> 

257 <i data-lucide="panel-left-open" class="w-5 h-5 sidebar-collapsed-icon"></i> 

258 </button> 

259 """ 

260 

261 def _render_footer(self) -> str: 

262 """Render sidebar footer.""" 

263 return f""" 

264 <div class="sidebar-footer"> 

265 {self.config.footer_text} 

266 </div> 

267 """ 

268 

269 

270def build_nav_from_resources( 

271 resources: list[Any], 

272 current_path: str = "/", 

273 base_url: str = "/admin", 

274) -> list[NavGroup]: 

275 """Build navigation groups from resource configuration. 

276 

277 Args: 

278 resources: List of resource instances or configs 

279 current_path: Current path for active highlighting 

280 base_url: Base URL prefix 

281 

282 Returns: 

283 List of NavGroup for sidebar 

284 """ 

285 # Group items by category 

286 categories: dict[str, list[NavItem]] = {} 

287 

288 for resource in resources: 

289 # Extract resource info 

290 name = getattr(resource, "name", str(resource)) 

291 label = getattr(resource, "label", name.replace("_", " ").title()) 

292 icon = getattr(resource, "icon", "file") 

293 category = getattr(resource, "category", "General") 

294 url = f"{base_url}/{name}" 

295 

296 item = NavItem( 

297 label=label, 

298 url=url, 

299 icon=icon, 

300 is_active=current_path.startswith(url), 

301 ) 

302 

303 if category not in categories: 

304 categories[category] = [] 

305 categories[category].append(item) 

306 

307 # Convert to groups 

308 groups: list[NavGroup] = [] 

309 for category_name, items in categories.items(): 

310 groups.append( 

311 NavGroup( 

312 label=category_name, 

313 items=items, 

314 is_collapsible=True, 

315 ), 

316 ) 

317 

318 return groups 

319 

320 

321__all__ = [ 

322 "NavGroup", 

323 "NavItem", 

324 "SidebarConfig", 

325 "SidebarRenderer", 

326 "build_nav_from_resources", 

327]