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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui import Component, el 

6 

7 

8class LanguageSwitcher(Component): 

9 """Dropdown language switcher for the admin UI. 

10 

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``. 

14 

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

22 

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 

34 

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 ) 

63 

64 

65class ThemeToggle(Component): 

66 """ 

67 Client-side theme toggle powered by Alpine.js and localStorage. 

68 """ 

69 

70 def render(self) -> Any: 

71 from lexigram.ui import ToggleIcon 

72 

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

80 

81 

82# UserBox has been moved to its own module for clarity and reusability. 

83 

84 

85class TopBar(Component): 

86 """ 

87 Evolution of TopBar with extensible slots and refined aesthetics. 

88 """ 

89 

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 

109 

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 

115 

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

126 

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 ) 

150 

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 

155 

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 ) 

164 

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 ) 

195 

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 )