Coverage for src / lexigram / admin / ui / organisms / command_palette.py: 39%

18 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.admin.settings import get_admin_settings 

6from lexigram.ui import Component, el, raw 

7 

8 

9class CommandPalette(Component): 

10 """ 

11 A global command palette (Cmd+K) for quick navigation and actions. 

12 Powered by Alpine.js for state and keyboard handling. 

13 """ 

14 

15 def __init__(self, commands: list[dict[str, str]] | None = None, **props) -> None: 

16 super().__init__(commands=commands or [], **props) 

17 self.commands = commands or [ 

18 { 

19 "label": "Go to Dashboard", 

20 "href": "/admin/", 

21 "icon": "home", 

22 "shortcut": "G D", 

23 }, 

24 { 

25 "label": "Manage Users", 

26 "href": f"{get_admin_settings().build_htmx_path('UserResource')}/", 

27 "icon": "users", 

28 "shortcut": "G U", 

29 }, 

30 { 

31 "label": "Toggle Dark Mode", 

32 "action": "darkMode = !darkMode", 

33 "icon": "moon", 

34 "shortcut": "T D", 

35 }, 

36 {"label": "Settings", "href": "#", "icon": "settings", "shortcut": ","}, 

37 ] 

38 

39 def render(self) -> Any: 

40 from lexigram.ui import get_icon 

41 

42 # Pre-process commands to include rendered icon HTML 

43 processed_commands = [] 

44 for cmd in self.commands: 

45 c = cmd.copy() 

46 # Render icon to string 

47 icon_node = get_icon( 

48 c.get("icon", ""), 

49 class_name="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors", 

50 ) 

51 c["icon_html"] = str(icon_node) 

52 processed_commands.append(c) 

53 

54 # Alpine.js state for the palette (kept for reference) 

55 _x_data = { 

56 "open": False, 

57 "search": "", 

58 "searchTimeout": None, 

59 "selectedIndex": 0, 

60 "commands": processed_commands, 

61 "staticCommands": processed_commands, 

62 } 

63 

64 # Overlay and Modal structure 

65 return el( 

66 "div", 

67 { 

68 "x-data": "commandPalette", 

69 "x-on:open-command-palette.window": "toggle()", 

70 "x-on:keydown.window.cmd.k.prevent": "toggle()", 

71 "x-on:keydown.window.ctrl.k.prevent": "toggle()", 

72 "x-on:keydown.window.escape": "close()", 

73 "x-show": "open", 

74 "class": "fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20", 

75 "role": "dialog", 

76 "aria-modal": "true", 

77 "x-cloak": True, 

78 }, 

79 # Backdrop 

80 el( 

81 "div", 

82 { 

83 "x-show": "open", 

84 "x-transition:enter": "transition-opacity ease-out duration-300", 

85 "x-transition:enter-start": "opacity-0", 

86 "x-transition:enter-end": "opacity-100", 

87 "x-transition:leave": "transition-opacity ease-in duration-200", 

88 "x-transition:leave-start": "opacity-100", 

89 "x-transition:leave-end": "opacity-0", 

90 "class": "fixed inset-0 bg-muted/60 backdrop-blur-sm transition-opacity", 

91 "x-on:click": "close()", 

92 }, 

93 ), 

94 # Palette Container 

95 el( 

96 "div", 

97 { 

98 "x-show": "open", 

99 "x-transition:enter": "transition-all ease-out duration-300", 

100 "x-transition:enter-start": "opacity-0 scale-95", 

101 "x-transition:enter-end": "opacity-100 scale-100", 

102 "x-transition:leave": "transition-all ease-in duration-200", 

103 "x-transition:leave-start": "opacity-100 scale-100", 

104 "x-transition:leave-end": "opacity-0 scale-95", 

105 "class": "mx-auto max-w-2xl transform divide-y divide-border overflow-hidden rounded-2xl bg-background shadow-2xl ring-1 ring-border transition-all", 

106 }, 

107 # Search Input 

108 el( 

109 "div", 

110 el( 

111 "div", 

112 get_icon("search", class_name="h-5 w-5 text-muted-foreground"), 

113 class_="pointer-events-none absolute left-4 top-3.5 h-5 w-5", 

114 ), 

115 el( 

116 "input", 

117 type="text", 

118 class_="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-foreground placeholder:text-muted-foreground focus:ring-0 sm:text-sm", 

119 placeholder="Search commands or navigation...", 

120 x_model="search", 

121 x_on_keydown_down="next()", 

122 x_on_keydown_up="prev()", 

123 x_on_keydown_enter="execute()", 

124 ), 

125 class_="relative", 

126 ), 

127 # Results list 

128 el( 

129 "ul", 

130 { 

131 "class": "max-h-96 scroll-py-3 overflow-y-auto p-3", 

132 "id": "options", 

133 "role": "listbox", 

134 }, 

135 el( 

136 "template", 

137 { 

138 "x-for": "(command, index) in filteredCommands", 

139 ":key": "command.label", 

140 }, 

141 el( 

142 "li", 

143 { 

144 "class": "group flex cursor-default select-none items-center rounded-xl p-3", 

145 ":class": "selectedIndex === index ? 'bg-primary-600 text-white' : 'text-foreground hover:bg-muted dark:hover:bg-card'", 

146 "id": "option-1", 

147 "role": "option", 

148 "tabindex": "-1", 

149 "x_on_click": "execute(index)", 

150 "x_on_mouseenter": "selectedIndex = index", 

151 }, 

152 # Icon 

153 el( 

154 "div", 

155 { 

156 "class": "flex h-10 w-10 flex-none items-center justify-center rounded-lg", 

157 ":class": "selectedIndex === index ? 'bg-primary-500' : 'bg-muted dark:bg-card'", 

158 }, 

159 el( 

160 "div", 

161 { 

162 "x-html": "command.icon_html", 

163 "class": "flex items-center justify-center", 

164 }, 

165 ), 

166 ), 

167 # Label 

168 el( 

169 "div", 

170 el( 

171 "p", 

172 { 

173 "x-text": "command.label", 

174 "class": "font-semibold", 

175 }, 

176 ), 

177 class_="ml-4 flex-auto", 

178 ), 

179 # Shortcut 

180 el( 

181 "span", 

182 { 

183 "x-show": "command.shortcut", 

184 "x-text": "command.shortcut", 

185 "class": "ml-3 flex-none text-xs font-semibold", 

186 ":class": "selectedIndex === index ? 'text-primary-100' : 'text-muted-foreground'", 

187 }, 

188 ), 

189 ), 

190 ), 

191 ), 

192 # Empty state 

193 el( 

194 "div", 

195 el( 

196 "p", 

197 "No results found for that search.", 

198 class_="p-10 text-center text-sm text-muted-foreground", 

199 ), 

200 x_show="search !== '' && filteredCommands.length === 0", 

201 ), 

202 # Help footer 

203 el( 

204 "div", 

205 el( 

206 "div", 

207 el( 

208 "span", 

209 "esc", 

210 class_="rounded-md border border-border px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground", 

211 ), 

212 el( 

213 "span", 

214 " to close", 

215 class_="ml-1 text-xs text-muted-foreground", 

216 ), 

217 class_="flex items-center", 

218 ), 

219 el( 

220 "div", 

221 el( 

222 "span", 

223 "enter", 

224 class_="rounded-md border border-border px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground", 

225 ), 

226 el( 

227 "span", 

228 " to select", 

229 class_="ml-1 text-xs text-muted-foreground", 

230 ), 

231 class_="flex items-center ml-4", 

232 ), 

233 class_="flex flex-none items-center justify-end bg-muted dark:bg-card/50 px-4 py-2.5", 

234 ), 

235 ), 

236 # Inline script to register Alpine data 

237 el( 

238 "script", 

239 raw( 

240 f""" 

241 document.addEventListener('alpine:init', () => {{ 

242 Alpine.data('commandPalette', () => ({{ 

243 open: false, 

244 search: '', 

245 selectedIndex: 0, 

246 searchTimeout: null, 

247 commands: {processed_commands}, 

248 staticCommands: {processed_commands}, 

249 get filteredCommands() {{ 

250 return this.commands; 

251 }}, 

252 init() {{ 

253 this.$watch('search', (value) => {{ 

254 clearTimeout(this.searchTimeout); 

255 this.selectedIndex = 0; 

256 this.searchTimeout = setTimeout(() => {{ 

257 this.fetchResults(value); 

258 }}, 200); 

259 }}); 

260 }}, 

261 async fetchResults(query) {{ 

262 if (query.length < 2) {{ 

263 this.commands = this.staticCommands; 

264 return; 

265 }} 

266 try {{ 

267 const response = await fetch(`/admin/command-palette?q=${{encodeURIComponent(query)}}`); 

268 const data = await response.json(); 

269 this.commands = data; 

270 }} catch (e) {{ 

271 this.commands = this.staticCommands; 

272 }} 

273 }}, 

274 toggle() {{ 

275 this.open = !this.open; 

276 if (this.open) {{ 

277 this.search = ''; 

278 this.selectedIndex = 0; 

279 setTimeout(() => this.$el.querySelector('input').focus(), 50); 

280 }} 

281 }}, 

282 close() {{ 

283 this.open = false; 

284 }}, 

285 next() {{ 

286 this.selectedIndex = (this.selectedIndex + 1) % this.filteredCommands.length; 

287 }}, 

288 prev() {{ 

289 this.selectedIndex = (this.selectedIndex - 1 + this.filteredCommands.length) % this.filteredCommands.length; 

290 }}, 

291 execute(idx = null) {{ 

292 const index = idx !== null ? idx : this.selectedIndex; 

293 const command = this.filteredCommands[index]; 

294 if (!command) return; 

295 

296 this.close(); 

297 

298 if (command.href) {{ 

299 if (command.href.startsWith('/')) {{ 

300 // Use htmx if possible 

301 if (window.htmx) {{ 

302 htmx.ajax('GET', command.href, {{target:'#main-content', swap:'innerHTML'}}) 

303 window.history.pushState({{}}, '', command.href); 

304 }} else {{ 

305 window.location.href = command.href; 

306 }} 

307 }} else {{ 

308 window.location.href = command.href; 

309 }} 

310 }} else if (command.action) {{ 

311 // Find the shell's x-data to execute actions 

312 const shell = document.querySelector('[x-data*="darkMode"]'); 

313 if (shell) {{ 

314 const data = Alpine.$data(shell); 

315 if (command.action.includes('darkMode')) {{ 

316 data.darkMode = !data.darkMode; 

317 }} 

318 }} 

319 }} 

320 }} 

321 }})) 

322 }}) 

323 """, 

324 ), 

325 ), 

326 )