Coverage for src / lexigram / admin / ui / layouts / components / sidebar.py: 36%
126 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Sidebar navigation component for admin layout.
3Renders the sidebar with navigation items, groups, and collapse functionality.
4"""
6from __future__ import annotations
8from dataclasses import dataclass, field
9from typing import Any
11from markupsafe import escape
14@dataclass
15class NavItem:
16 """Single navigation item."""
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)
30@dataclass
31class NavGroup:
32 """Navigation group with optional header."""
34 label: str | None = None
35 items: list[NavItem] = field(default_factory=list)
36 is_collapsible: bool = False
37 is_collapsed: bool = False
40@dataclass
41class SidebarConfig:
42 """Configuration for sidebar."""
44 # Display
45 width: str = "260px"
46 collapsed_width: str = "64px"
47 is_collapsible: bool = True
48 default_collapsed: bool = False
50 # Features
51 show_footer: bool = True
52 footer_text: str = ""
54 # Styling
55 bg_class: str = "bg-background"
56 text_class: str = "text-foreground"
58 # Branding (optional, if not in header)
59 show_logo: bool = False
60 logo_url: str | None = None
61 site_name: str = "Admin"
64class SidebarRenderer:
65 """Renders the sidebar navigation."""
67 def __init__(
68 self,
69 config: SidebarConfig | None = None,
70 groups: list[NavGroup] | None = None,
71 ):
72 """Initialize the renderer.
74 Args:
75 config: Sidebar configuration
76 groups: Navigation groups
77 """
78 self.config = config or SidebarConfig()
79 self.groups = groups or []
81 def render(self, current_path: str = "/") -> str:
82 """Render the sidebar.
84 Args:
85 current_path: Current request path for highlighting active item
87 Returns:
88 HTML string for sidebar
89 """
90 parts: list[str] = []
92 collapsed_class = "sidebar-collapsed" if self.config.default_collapsed else ""
94 parts.append(
95 f'<aside class="admin-sidebar {collapsed_class}" id="admin-sidebar">',
96 )
98 # Mobile overlay
99 parts.append(
100 '<div class="sidebar-overlay lg:hidden" onclick="closeSidebar()"></div>',
101 )
103 # Sidebar content wrapper
104 parts.append('<div class="sidebar-content">')
106 # Optional logo section
107 if self.config.show_logo:
108 parts.append(self._render_logo())
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>")
116 # Collapse toggle
117 if self.config.is_collapsible:
118 parts.append(self._render_collapse_toggle())
120 # Footer
121 if self.config.show_footer and self.config.footer_text:
122 parts.append(self._render_footer())
124 parts.append("</div>") # sidebar-content
125 parts.append("</aside>")
127 return "\n".join(parts)
129 def _render_logo(self) -> str:
130 """Render sidebar logo section."""
131 parts: list[str] = []
133 parts.append('<div class="sidebar-logo">')
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 )
144 parts.append("</div>")
146 return "\n".join(parts)
148 def _render_nav_group(self, group: NavGroup, current_path: str) -> str:
149 """Render a navigation group."""
150 parts: list[str] = []
152 collapsed_class = "is-collapsed" if group.is_collapsed else ""
153 parts.append(f'<div class="nav-group {collapsed_class}">')
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 )
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>")
176 parts.append("</div>")
178 return "\n".join(parts)
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 )
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 )
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}"
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)}"'
206 # Badge
207 badge = ""
208 if item.badge:
209 badge = f'<span class="nav-badge badge-{escape(item.badge_color)}">{escape(item.badge)}</span>'
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 )
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 """)
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 """)
246 parts.append("</li>")
248 return "\n".join(parts)
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 """
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 """
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.
277 Args:
278 resources: List of resource instances or configs
279 current_path: Current path for active highlighting
280 base_url: Base URL prefix
282 Returns:
283 List of NavGroup for sidebar
284 """
285 # Group items by category
286 categories: dict[str, list[NavItem]] = {}
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}"
296 item = NavItem(
297 label=label,
298 url=url,
299 icon=icon,
300 is_active=current_path.startswith(url),
301 )
303 if category not in categories:
304 categories[category] = []
305 categories[category].append(item)
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 )
318 return groups
321__all__ = [
322 "NavGroup",
323 "NavItem",
324 "SidebarConfig",
325 "SidebarRenderer",
326 "build_nav_from_resources",
327]