Coverage for src / lexigram / admin / ui / organisms / sidebar.py: 13%
92 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
1from __future__ import annotations
3from typing import Any
5from lexigram.ui import Component, el
8class SidebarItem(Component):
9 """
10 Individual navigation item for the sidebar.
11 Supports icons, badges, and active state detection.
12 """
14 def __init__(
15 self,
16 label: str,
17 href: str,
18 icon: str | Any = None,
19 badge: str | int | None = None,
20 active: bool = False,
21 **props,
22 ) -> None:
23 super().__init__(
24 label=label,
25 href=href,
26 icon=icon,
27 badge=badge,
28 active=active,
29 **props,
30 )
31 self.label = label
32 self.href = href
33 self.icon = icon
34 self.badge = badge
35 self.active = active
37 def render(self) -> Any:
38 # Determine base classes based on active state
39 if self.active:
40 bg_cls = "bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400"
41 icon_cls = "text-primary-600 dark:text-primary-400"
42 else:
43 bg_cls = "text-muted-foreground dark:text-muted-foreground hover:bg-muted dark:hover:bg-card/50 hover:text-primary-600 dark:hover:text-primary-400"
44 icon_cls = "text-muted-foreground group-hover:text-primary-600 dark:group-hover:text-primary-400"
46 # Icon rendering
47 icon_node = ""
48 has_icon = bool(self.icon)
50 if has_icon:
51 if hasattr(self.icon, "__html__") or isinstance(self.icon, Component):
52 icon = self.icon
53 else:
54 from lexigram.ui import get_icon
56 icon = get_icon(self.icon, class_name=icon_cls)
58 # Icon Container: Show in both modes. Handle margin dynamically.
59 # In wide mode (!mini): mr-3. In mini mode: mr-0 (centered by parent justify-center).
60 icon_node = el(
61 "div",
62 icon,
63 class_="transition-all duration-200",
64 **{"x-bind:class": "sidebarMini ? 'mr-0' : 'mr-3'"},
65 )
67 # Badge rendering
68 badge_node = ""
69 if self.badge is not None:
70 # Hide badge in mini mode or show small dot? For now, standard badge, hidden if no space?
71 # Let's keep it but it might wrap.
72 badge_node = el(
73 "span",
74 str(self.badge),
75 class_="ml-auto inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300",
76 x_show="!sidebarMini", # Hide badge in mini mode to save space
77 )
79 # Labels
80 label_text = self.label.replace("_", " ").title()
81 first_letter = label_text[0] if label_text else ""
83 # Mini Label (First Letter) logic:
84 # If has_icon: First Letter is NEVER shown (Icon takes precedence).
85 # If !has_icon: First Letter is SHOWN in mini mode only.
87 first_letter_node = ""
88 if not has_icon:
89 # Use x-bind:class for visibility keying off sidebarMini.
90 # Note: We need 'flex' when visible. 'hidden' when not.
91 # x-show works but manual class toggle is sometimes more robust in complex layouts.
92 first_letter_node = el(
93 "span",
94 first_letter,
95 class_="font-bold text-xs w-6 h-6 items-center justify-center shrink-0 rounded-lg bg-primary-100 dark:bg-primary-900 text-primary-600 dark:text-primary-300 border border-primary-200 dark:border-primary-800",
96 **{"x-bind:class": "sidebarMini ? 'flex' : 'hidden'"},
97 )
99 return el(
100 "a",
101 icon_node,
102 # Full Label
103 el(
104 "span",
105 label_text,
106 class_="truncate whitespace-nowrap",
107 x_show="!sidebarMini",
108 ),
109 # Mini Label (First Letter)
110 first_letter_node,
111 badge_node,
112 href=self.href,
113 class_=f"group flex items-center px-3 py-2 text-sm font-medium rounded-xl transition-all duration-200 {bg_cls}",
114 # Use Alpine dict syntax for directives
115 **{
116 "x-bind:class": "sidebarMini ? 'justify-center' : ''",
117 "aria-current": "page" if self.active else "false",
118 },
119 title=self.label,
120 )
123class SidebarSection(Component):
124 """
125 A labeled group of sidebar items.
126 """
128 def __init__(self, title: str, items: list[SidebarItem], **props) -> None:
129 super().__init__(title=title, items=items, **props)
130 self.title = title
131 self.items = items
133 def render(self) -> Any:
134 from lexigram.ui import get_icon
136 # Group Icon Logic
137 # If group has an icon, use it. If not, use a dot.
138 group_icon_node = ""
139 bool(
140 self.items and hasattr(self, "icon") and self.icon,
141 ) # Check if passed in init? SidebarSection doesn't capture icon in init explicitly in previous code, let's fix that or assume it might be in props
143 # The provider passes 'icon' in props usually.
144 # Let's check props for icon
145 icon_name = getattr(self, "icon", None) or self.props.get("icon")
147 if icon_name:
148 # Remove mr-2 from icon itself, wrapper handles spacing
149 group_icon_node = get_icon(
150 icon_name,
151 class_name="w-5 h-5 text-muted-foreground transition-colors group-hover:text-foreground dark:text-muted-foreground dark:group-hover:text-foreground",
152 )
153 else:
154 # Dot icon
155 group_icon_node = el(
156 "div", class_="w-1.5 h-1.5 rounded-full bg-muted-foreground/40"
157 )
159 # Collapsible Header
160 header = el(
161 "button",
162 # Icon / Dot Wrapper - Fixed width to align with tree border
163 # px-3 (12px) + w-5 (20px)/2 = 22px center
164 el(
165 "div",
166 group_icon_node,
167 class_="w-5 h-5 flex items-center justify-center transition-all duration-200",
168 **{"x-bind:class": "sidebarMini ? 'mr-0' : 'mr-3'"},
169 ),
170 # Title
171 el(
172 "span",
173 self.title,
174 class_="flex-1 text-left text-xs font-semibold uppercase tracking-wider whitespace-nowrap",
175 x_show="!sidebarMini",
176 ),
177 # Chevron
178 el(
179 "div",
180 get_icon("chevron-down", class_name="w-4 h-4"),
181 class_="text-muted-foreground transition-transform duration-200",
182 **{
183 "x-bind:class": "expanded ? 'rotate-0' : '-rotate-90'",
184 "x-show": "!sidebarMini",
185 },
186 ),
187 type="button",
188 class_="w-full flex items-center px-3 mt-4 mb-2 text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-0 group",
189 title=self.title,
190 **{
191 "x-on:click": "expanded = !expanded",
192 "x-bind:class": "sidebarMini ? 'justify-center' : ''",
193 "x-bind:aria-expanded": "expanded ? 'true' : 'false'",
194 },
195 )
197 # Items Container
198 # Tree border alignment:
199 # Header Button px-3 (12px)
200 # Icon Wrapper w-5 (20px). Center is 12 + 10 = 22px.
201 # Border-l should be at 22px.
202 # ml-[1.35rem] is roughly 22px (0.35rem = 5.6px? No. 1.375rem = 22px).
203 items_container = el(
204 "div",
205 *self.items,
206 class_="space-y-1 relative ml-[1.375rem] pl-3 border-l border-border",
207 # Hide border in mini mode
208 **{
209 "x-show": "expanded || sidebarMini",
210 # Use inline styles for layout reset to ensure they apply regardless of Tailwind scanning
211 "x-bind:style": "sidebarMini ? { marginLeft: '0', paddingLeft: '0', borderLeft: '0' } : {}",
212 },
213 )
215 section_key = self.title.lower().replace(" ", "-").replace("/", "-")
216 return el(
217 "div",
218 header,
219 items_container,
220 class_="sidebar-section",
221 **{
222 "x-data": "{ expanded: localStorage.getItem('section-"
223 + section_key
224 + "') === 'true' }",
225 "x-init": "$watch('expanded', val => localStorage.setItem('section-"
226 + section_key
227 + "', val))",
228 },
229 )
232class Sidebar(Component):
233 """
234 Main Sidebar container with Logo, Navigation Sections, and User profile.
235 """
237 def __init__(
238 self,
239 items: list[SidebarItem | SidebarSection],
240 user: Any | None = None,
241 logo_text: str = "Lexigram",
242 logo_url: str = "",
243 user_menu_items: list[dict] | None = None,
244 system_menu_items: list[dict] | None = None,
245 raw_user: Any | None = None,
246 **props,
247 ) -> None:
248 # Standardize user as a dict for easier access
249 user_dict = {}
250 if user:
251 if isinstance(user, dict):
252 user_dict = user
253 elif hasattr(user, "model_dump"): # Pydantic v2
254 user_dict = user.model_dump()
255 elif hasattr(user, "dict"): # Pydantic v1
256 user_dict = user.dict()
257 elif hasattr(user, "__dict__"):
258 user_dict = user.__dict__
259 else:
260 # Fallback: try to convert via dict() or just store as is if it supports get
261 try:
262 user_dict = dict(user)
263 except (TypeError, ValueError):
264 pass
265 super().__init__(
266 items=items,
267 user=user_dict,
268 logo_text=logo_text,
269 logo_url=logo_url,
270 user_menu_items=user_menu_items,
271 raw_user=raw_user,
272 **props,
273 )
274 self.items = items
275 self.user = user_dict
276 self.logo_text = logo_text
277 self.logo_url = logo_url
278 self.user_menu_items = user_menu_items or []
279 self.system_menu_items = system_menu_items or []
280 self.raw_user = raw_user
282 def render(self) -> Any:
283 from lexigram.ui import SystemBox, UserBox
285 # Logo Icon - Navigates to /admin dashboard
286 if self.logo_url:
287 logo_icon = el(
288 "a",
289 el(
290 "img",
291 src=self.logo_url,
292 alt=self.logo_text,
293 class_="w-10 h-10 rounded-xl object-contain flex-shrink-0",
294 ),
295 class_="block flex-shrink-0",
296 href="/admin",
297 )
298 else:
299 logo_icon = el(
300 "a",
301 el("span", self.logo_text[0].upper(), class_="text-white"),
302 class_="w-10 h-10 bg-primary-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/20 flex-shrink-0 cursor-pointer hover:bg-primary-700 transition-colors",
303 href="/admin",
304 )
306 # Toggle Button - chevron to collapse/expand sidebar
307 from lexigram.ui import get_icon
309 toggle_btn = el(
310 "button",
311 get_icon(
312 "chevron-left",
313 class_name="w-4 h-4 text-muted-foreground hover:text-muted-foreground dark:hover:text-foreground transition-colors",
314 ),
315 type="button",
316 class_="p-1 rounded-full border border-border hover:bg-muted dark:hover:bg-card transition-colors focus:outline-none focus:ring-0",
317 **{
318 "x-on:click": "sidebarMini = !sidebarMini",
319 "x-bind:class": "sidebarMini ? 'rotate-180' : ''",
320 "aria-label": "Toggle sidebar",
321 },
322 )
324 header = el(
325 "div",
326 # Icon Area
327 logo_icon,
328 # Text (Hidden in mini)
329 el(
330 "span",
331 self.logo_text,
332 class_="font-bold text-xl text-foreground tracking-tight ml-3 whitespace-nowrap",
333 x_show="!sidebarMini",
334 ),
335 class_="p-4 flex items-center h-16 border-b border-border",
336 )
338 # Navigation Area
339 nav = el(
340 "nav",
341 *self.items,
342 class_="flex-1 px-3 py-4 space-y-1 overflow-y-auto custom-scrollbar",
343 role="navigation",
344 aria_label="Main navigation",
345 )
347 # User Footer
348 def _get_user_val(key, default=None) -> Any:
349 if isinstance(self.user, dict):
350 return self.user.get(key, default)
351 return getattr(self.user, key, default)
353 system_menu_bar = SystemBox(
354 system_menu_items=self.system_menu_items,
355 direction="up",
356 user=self.raw_user,
357 )
359 user_node = UserBox(
360 _get_user_val("username") or _get_user_val("name", "Admin"),
361 avatar_url=_get_user_val("avatar") or _get_user_val("avatar_url"),
362 direction="up", # Open upwards to avoid clipping/overflow
363 position="left", # Open to the right (left-aligned) to avoid clipping off-screen in mini mode
364 roles=_get_user_val("roles", []),
365 user_menu_items=self.user_menu_items,
366 user=self.raw_user,
367 )
368 footer = el(
369 "div",
370 system_menu_bar,
371 el(
372 "div",
373 user_node,
374 toggle_btn,
375 class_="flex items-center gap-1 px-2 py-1",
376 ),
377 class_="dark:border-border",
378 )
380 return el(
381 "aside",
382 header,
383 nav,
384 footer,
385 class_="relative flex flex-col h-full bg-card dark:bg-background border-r border-border transition-all duration-300 ease-in-out",
386 **{"id": "main-sidebar", "x-bind:class": "sidebarMini ? 'w-24' : 'w-72'"},
387 role="complementary",
388 aria_label="Sidebar navigation",
389 )