Coverage for src / lexigram / admin / ui / organisms / filter_drawer.py: 20%
55 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Stacked/sidebar filter drawer — slide-over panel with all filters.
3Provides a FilamentPHP-style ``FilterDrawer`` component that:
4- Opens as a slide-over panel from the right on click of a "Filters" button
5- Stacks all filter controls vertically for readability
6- Shows an active-filter count badge on the trigger button
7- Submits via HTMX on "Apply" and resets on "Clear"
8"""
10from __future__ import annotations
12from typing import Any
14from lexigram.ui import Component, Zones, el
17class FilterDrawer(Component):
18 """Slide-over filter panel with stacked filter controls.
20 Renders a "Filters" trigger button whose badge shows the number of active
21 filters, and a slide-over panel that contains the full filter form. The
22 panel is driven by Alpine.js (``filterDrawerOpen`` state) so it requires
23 no page reload to open/close.
25 Args:
26 filters: Same format as ``FilterBar.filters`` — dict of
27 ``{field_name: {"type": ..., "options": [...], ...}}``.
28 current_values: Current active filter values.
29 resource_prefix: Resource URL prefix for HTMX ``hx-get`` attributes.
30 state: Optional ``TableState`` for building HTMX attrs on apply.
31 """
33 def __init__(
34 self,
35 filters: list[Any] | dict[str, Any] | None = None,
36 current_values: dict[str, Any] | None = None,
37 resource_prefix: str | None = None,
38 state: Any | None = None,
39 **props: Any,
40 ) -> None:
41 super().__init__(**props)
42 self.filters = filters or []
43 self.current_values = current_values or {}
44 self.resource_prefix = resource_prefix
45 self.state = state
47 # ------------------------------------------------------------------
48 # Public render
49 # ------------------------------------------------------------------
51 def render(self) -> Any:
52 active_count = self._active_filter_count()
53 trigger = self._render_trigger(active_count)
54 panel = self._render_panel()
55 backdrop = self._render_backdrop()
57 # NOTE: Requires the Alpine.js Persist plugin (@alpinejs/persist).
58 # Include it before Alpine core:
59 # <script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
60 # The $persist() call keeps filterDrawerOpen in localStorage so the
61 # drawer state survives page navigations (e.g. HTMX soft navigations).
62 return el(
63 "div",
64 trigger,
65 backdrop,
66 panel,
67 **{
68 "x-data": "{ filterDrawerOpen: $persist(false).as('lexigram_filter_drawer_open') }",
69 "class": "relative",
70 },
71 )
73 # ------------------------------------------------------------------
74 # Private helpers
75 # ------------------------------------------------------------------
77 def _active_filter_count(self) -> int:
78 """Return the number of filters that have a non-empty value."""
79 return sum(
80 1 for v in self.current_values.values() if v not in (None, "", [], {})
81 )
83 def _render_trigger(self, active_count: int) -> Any:
84 """Render the "Filters" button with optional count badge."""
85 badge = ""
86 if active_count > 0:
87 badge = el(
88 "span",
89 str(active_count),
90 class_=(
91 "inline-flex items-center justify-center w-5 h-5 ml-1 "
92 "text-xs font-bold text-primary-foreground bg-primary rounded-full"
93 ),
94 )
96 return el(
97 "button",
98 el(
99 "svg",
100 el(
101 "path",
102 **{
103 "stroke-linecap": "round",
104 "stroke-linejoin": "round",
105 "stroke-width": "2",
106 "d": "M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z",
107 },
108 ),
109 xmlns="http://www.w3.org/2000/svg",
110 fill="none",
111 viewBox="0 0 24 24",
112 stroke="currentColor",
113 class_="w-4 h-4 mr-1.5",
114 ),
115 "Filters",
116 badge,
117 type="button",
118 class_=(
119 "inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg "
120 "border border-border "
121 "bg-card "
122 "text-foreground "
123 "hover:bg-muted dark:hover:bg-muted "
124 "focus:outline-none focus:ring-2 focus:ring-ring "
125 "transition-colors"
126 ),
127 **{"@click": "filterDrawerOpen = true"},
128 )
130 def _render_backdrop(self) -> Any:
131 """Semi-transparent backdrop to close drawer on outside click."""
132 return el(
133 "div",
134 class_="fixed inset-0 bg-black/30 z-40",
135 **{
136 "x-show": "filterDrawerOpen",
137 "x-transition:enter": "transition ease-out duration-200",
138 "x-transition:enter-start": "opacity-0",
139 "x-transition:enter-end": "opacity-100",
140 "x-transition:leave": "transition ease-in duration-150",
141 "x-transition:leave-start": "opacity-100",
142 "x-transition:leave-end": "opacity-0",
143 "x-cloak": "",
144 "@click": "filterDrawerOpen = false",
145 },
146 )
148 def _render_panel(self) -> Any:
149 """Render the slide-over panel body with filter controls."""
150 filter_controls = self._build_filter_controls()
151 apply_url = self.resource_prefix.rstrip("/") if self.resource_prefix else "#"
153 res_prefix = (self.resource_prefix or "").strip("/").replace("/", "_")
154 if not res_prefix:
155 res_prefix = "global"
157 apply_attrs: dict[str, Any] = {
158 "hx-get": apply_url,
159 "hx-target": Zones.DATA.selector,
160 "hx-swap": Zones.DATA.swap_mode.value,
161 "hx-select": Zones.DATA.selector,
162 "hx-push-url": "true",
163 "hx-include": f"{Zones.DATA.selector} [data-state='true'], [data-filter-field]",
164 # Close the drawer and persist current filter values to localStorage
165 # so they can be restored on next page load (read by x-init on each
166 # filter input via x-model and $persist).
167 "@click": (
168 "filterDrawerOpen = false; "
169 "document.querySelectorAll('[data-filter-field]').forEach(function(el) { "
170 f" var key = 'lexigram_filter_{res_prefix}_' + (el.name || el.id || ''); "
171 f" if (key !== 'lexigram_filter_{res_prefix}_') {{ localStorage.setItem(key, el.value); }} "
172 "});"
173 ),
174 }
176 reset_href = apply_url
177 if self.resource_prefix:
178 reset_href = apply_url # clear all → GET without filter params
180 return el(
181 "div",
182 # Header
183 el(
184 "div",
185 el(
186 "h2",
187 "Filters",
188 class_=("text-lg font-semibold text-foreground"),
189 ),
190 el(
191 "button",
192 el(
193 "svg",
194 el(
195 "path",
196 **{
197 "stroke-linecap": "round",
198 "stroke-linejoin": "round",
199 "stroke-width": "2",
200 "d": "M6 18L18 6M6 6l12 12",
201 },
202 ),
203 xmlns="http://www.w3.org/2000/svg",
204 fill="none",
205 viewBox="0 0 24 24",
206 stroke="currentColor",
207 class_="w-5 h-5",
208 ),
209 type="button",
210 class_=(
211 "p-1 text-muted-foreground hover:text-muted-foreground "
212 "dark:hover:text-foreground rounded transition-colors"
213 ),
214 **{"@click": "filterDrawerOpen = false"},
215 ),
216 class_="flex items-center justify-between p-4 border-b border-border",
217 ),
218 # Filter controls
219 el(
220 "div",
221 *filter_controls,
222 class_="flex-1 overflow-y-auto p-4 space-y-4",
223 ),
224 # Footer actions
225 el(
226 "div",
227 el(
228 "a",
229 "Reset all",
230 href=reset_href,
231 class_=(
232 "px-4 py-2 text-sm font-medium text-foreground "
233 "border border-border rounded-lg "
234 "hover:bg-muted dark:hover:bg-muted transition-colors"
235 ),
236 **{
237 "hx-get": reset_href,
238 "hx-target": Zones.DATA.selector,
239 "hx-swap": Zones.DATA.swap_mode.value,
240 "hx-select": Zones.DATA.selector,
241 "hx-push-url": "true",
242 "@click": "filterDrawerOpen = false",
243 },
244 ),
245 el(
246 "button",
247 "Apply filters",
248 type="button",
249 class_=(
250 "px-4 py-2 text-sm font-medium text-white "
251 "bg-primary hover:bg-primary/90 rounded-lg "
252 "focus:outline-none focus:ring-2 focus:ring-ring "
253 "transition-colors"
254 ),
255 **apply_attrs,
256 ),
257 class_=(
258 "flex items-center justify-end gap-3 p-4 border-t border-border"
259 ),
260 ),
261 # Panel wrapper
262 class_=(
263 "fixed top-0 right-0 bottom-0 z-50 flex flex-col "
264 "w-80 max-w-full "
265 "bg-card shadow-xl"
266 ),
267 **{
268 "x-show": "filterDrawerOpen",
269 "x-transition:enter": "transition ease-out duration-250",
270 "x-transition:enter-start": "translate-x-full",
271 "x-transition:enter-end": "translate-x-0",
272 "x-transition:leave": "transition ease-in duration-200",
273 "x-transition:leave-start": "translate-x-0",
274 "x-transition:leave-end": "translate-x-full",
275 "x-cloak": "",
276 "@click.stop": "",
277 },
278 )
280 def _build_filter_controls(self) -> list[Any]:
281 """Build stacked filter controls for the panel."""
282 controls: list[Any] = []
284 if isinstance(self.filters, list):
285 for f in self.filters:
286 if not hasattr(f, "render"):
287 continue
288 label = getattr(f, "label", getattr(f, "field_name", ""))
289 current_val = self.current_values.get(
290 getattr(f, "field_name", ""), None
291 )
292 if hasattr(f, "set_state"):
293 f.set_state(self.state)
294 rendered = f.render(
295 current_val,
296 url=self.resource_prefix.rstrip("/")
297 if self.resource_prefix
298 else None,
299 )
300 controls.append(
301 el(
302 "div",
303 el(
304 "label",
305 str(label),
306 class_="block text-sm font-medium text-foreground mb-1",
307 ),
308 el("div", rendered, **{"data-filter-field": ""}),
309 class_="",
310 )
311 )
312 elif isinstance(self.filters, dict):
313 for field_name, opts in self.filters.items():
314 current_val = self.current_values.get(field_name, "")
315 label = (
316 opts.get("label", field_name.replace("_", " ").title())
317 if isinstance(opts, dict)
318 else field_name.replace("_", " ").title()
319 )
320 input_el = el(
321 "input",
322 type="text",
323 name=f"filter_{field_name}",
324 value=str(current_val) if current_val else "",
325 placeholder=f"Filter by {label.lower()}…",
326 class_=(
327 "block w-full rounded-md border border-border "
328 "bg-muted text-foreground "
329 "px-3 py-2 text-sm shadow-sm focus:outline-none "
330 "focus:ring-2 focus:ring-ring"
331 ),
332 **{"data-filter-field": ""},
333 )
334 controls.append(
335 el(
336 "div",
337 el(
338 "label",
339 label,
340 class_="block text-sm font-medium text-foreground mb-1",
341 ),
342 input_el,
343 )
344 )
346 return controls