Coverage for src / lexigram / admin / ui / organisms / topbar.py: 22%
49 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 LanguageSwitcher(Component):
9 """Dropdown language switcher for the admin UI.
11 Renders as a ``<select>`` that posts the chosen locale to *action_url*
12 (default ``/admin/set-locale``). On change it submits the wrapping form
13 automatically via Alpine.js ``x-on:change``.
15 Args:
16 locales: Ordered list of ``(code, label)`` pairs,
17 e.g. ``[("en", "English"), ("fr", "Français")]``.
18 current_locale: The currently active locale code.
19 action_url: URL that accepts a ``POST`` with ``locale=<code>``.
20 **props: Extra HTML attributes forwarded to the wrapper element.
21 """
23 def __init__(
24 self,
25 locales: list[tuple[str, str]] | None = None,
26 current_locale: str = "en",
27 action_url: str = "/admin/set-locale",
28 **props: Any,
29 ) -> None:
30 super().__init__(**props)
31 self.locales = locales or [("en", "English")]
32 self.current_locale = current_locale
33 self.action_url = action_url
35 def render(self) -> Any:
36 options = [
37 el(
38 "option",
39 label,
40 value=code,
41 selected=(code == self.current_locale) or None,
42 )
43 for code, label in self.locales
44 ]
45 select = el(
46 "select",
47 *options,
48 name="locale",
49 class_=(
50 "text-sm bg-transparent border border-border "
51 "rounded px-2 py-1 text-foreground "
52 "focus:outline-none focus:ring-2 focus:ring-ring cursor-pointer"
53 ),
54 **{"x-on:change": "$el.form.submit()"},
55 )
56 return el(
57 "form",
58 select,
59 method="POST",
60 action=self.action_url,
61 class_="inline-block",
62 )
65class ThemeToggle(Component):
66 """
67 Client-side theme toggle powered by Alpine.js and localStorage.
68 """
70 def render(self) -> Any:
71 from lexigram.ui import ToggleIcon
73 return ToggleIcon(
74 icon_on="moon",
75 icon_off="sun",
76 state_var="darkMode",
77 aria_label="Toggle theme",
78 size="sm",
79 ).render()
82# UserBox has been moved to its own module for clarity and reusability.
85class TopBar(Component):
86 """
87 Evolution of TopBar with extensible slots and refined aesthetics.
88 """
90 def __init__(
91 self,
92 title: str = "Admin",
93 user: Any | None = None,
94 user_menu_items: list[dict[str, Any]] | None = None,
95 left: Any | None = None,
96 center: Any | None = None,
97 right: Any | None = None,
98 site_name: str = "",
99 **props: Any,
100 ) -> None:
101 super().__init__(**props)
102 self.title_val = title
103 self.site_name = site_name
104 self.left = left
105 self.center = center
106 self.right = right
107 self.user = user
108 self.user_menu_items = user_menu_items
110 def render(self) -> Any:
111 # Default Left: Mobile toggle + Title
112 left_node = self.left
113 if left_node is None:
114 from lexigram.ui import ActionButton
116 toggle = ActionButton(
117 label="",
118 color="ghost",
119 icon="menu",
120 size="sm",
121 **{ # type: ignore[arg-type]
122 "x-on:click": "sidebarOpen = !sidebarOpen",
123 "class_": "mr-4 lg:hidden",
124 },
125 ).render()
127 title_text = self.title_val or "Admin"
128 site_elements = []
129 if self.site_name:
130 site_elements.append(
131 el(
132 "span",
133 self.site_name,
134 class_="text-xs text-muted-foreground uppercase tracking-wider",
135 ),
136 )
137 site_elements.append(
138 el(
139 "span",
140 title_text,
141 class_="text-lg font-bold text-foreground tracking-tight",
142 ),
143 )
144 left_node = el(
145 "div",
146 toggle,
147 el("div", *site_elements, class_="flex flex-col"),
148 class_="flex items-center",
149 )
151 # Default Right: NotificationBell + ThemeToggle in header (UserBox is sidebar-only)
152 right_node = self.right
153 if right_node is None:
154 from lexigram.ui import NotificationBell
156 right_elements = []
157 right_elements.append(NotificationBell().render())
158 right_elements.append(ThemeToggle())
159 right_node = el(
160 "div",
161 *right_elements,
162 class_="flex items-center space-x-3",
163 )
165 center_node = self.center or ""
166 if not center_node:
167 center_node = el(
168 "div",
169 el(
170 "div",
171 el(
172 "svg",
173 el(
174 "path",
175 stroke_linecap="round",
176 stroke_linejoin="round",
177 stroke_width="2",
178 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
179 ),
180 class_="w-5 h-5 text-muted-foreground",
181 fill="none",
182 viewBox="0 0 24 24",
183 stroke="currentColor",
184 ),
185 el(
186 "span",
187 "Search (Cmd+K)",
188 class_="ml-3 text-muted-foreground text-sm hidden sm:block",
189 ),
190 class_="flex items-center w-full max-w-md px-4 py-2 bg-muted rounded-lg cursor-pointer hover:bg-muted/80 transition-colors",
191 **{"x-on:click": "$dispatch('open-command-palette')"},
192 ),
193 class_="flex-1 flex justify-center max-w-2xl",
194 )
196 return el(
197 "header",
198 el(
199 "div",
200 el("div", left_node, class_="flex-shrink-0"),
201 el("div", center_node, class_="flex-1 flex justify-center px-4"),
202 el("div", right_node, class_="flex-shrink-0"),
203 class_="px-4 h-16 flex items-center justify-between",
204 ),
205 class_="bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-20 shadow-sm transition-colors duration-300",
206 )