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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui import Component, el 

6 

7 

8class SidebarItem(Component): 

9 """ 

10 Individual navigation item for the sidebar. 

11 Supports icons, badges, and active state detection. 

12 """ 

13 

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 

36 

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" 

45 

46 # Icon rendering 

47 icon_node = "" 

48 has_icon = bool(self.icon) 

49 

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 

55 

56 icon = get_icon(self.icon, class_name=icon_cls) 

57 

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 ) 

66 

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 ) 

78 

79 # Labels 

80 label_text = self.label.replace("_", " ").title() 

81 first_letter = label_text[0] if label_text else "" 

82 

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. 

86 

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 ) 

98 

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 ) 

121 

122 

123class SidebarSection(Component): 

124 """ 

125 A labeled group of sidebar items. 

126 """ 

127 

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 

132 

133 def render(self) -> Any: 

134 from lexigram.ui import get_icon 

135 

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 

142 

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") 

146 

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 ) 

158 

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 ) 

196 

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 ) 

214 

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 ) 

230 

231 

232class Sidebar(Component): 

233 """ 

234 Main Sidebar container with Logo, Navigation Sections, and User profile. 

235 """ 

236 

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 

281 

282 def render(self) -> Any: 

283 from lexigram.ui import SystemBox, UserBox 

284 

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 ) 

305 

306 # Toggle Button - chevron to collapse/expand sidebar 

307 from lexigram.ui import get_icon 

308 

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 ) 

323 

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 ) 

337 

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 ) 

346 

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) 

352 

353 system_menu_bar = SystemBox( 

354 system_menu_items=self.system_menu_items, 

355 direction="up", 

356 user=self.raw_user, 

357 ) 

358 

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 ) 

379 

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 )