Coverage for src/lexigram/admin/ui/organisms/command_palette.py: 28%
18 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.admin.settings import get_admin_settings
6from lexigram.ui import Component, el, raw
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 """
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 ]
39 def render(self) -> Any:
40 from lexigram.ui import get_icon
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)
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 }
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;
296 this.close();
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 )