Coverage for src/lexigram/admin/ui/molecules/filter_bar.py: 6%
78 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
1"""Filter bar component using existing UI components."""
3from __future__ import annotations
5from typing import Any
7from lexigram.ui import Component, el
10class FilterBar(Component):
11 """Filter bar with various input types using existing UI components."""
13 def __init__(
14 self,
15 filters: dict[str, dict[str, Any]] | None = None,
16 current_values: dict[str, Any] | None = None,
17 resource_prefix: str | None = None,
18 display: str = "auto",
19 state: Any | None = None,
20 **props: Any,
21 ) -> None:
22 """Initialize filter bar.
24 Add `display` param: 'auto'|'vertical'|'horizontal'.
26 Args:
27 filters: Dict of {field_name: {"type": "select|text|checkbox|date", "options": [...], ...}}
28 or a list of Filter objects
29 current_values: Dict of {field_name: current_value}
30 resource_prefix: Resource URL prefix for HTMX requests
31 state: Optional `TableState` instance to allow filters to include table HTMX attrs
32 """
33 super().__init__(**props)
34 self.filters = filters or {}
35 self.current_values = current_values or {}
36 self.resource_prefix = resource_prefix
37 # Display mode: auto, vertical, horizontal
38 self.display = props.get("display", display)
39 # Store optional TableState for child filters to use
40 self.state = state
42 def _detect_filter_type(self, field_name: str, options: Any) -> str:
43 """Auto-detect filter type based on field name and options."""
44 # Check field name patterns
45 if "date" in field_name.lower() or "time" in field_name.lower():
46 return "date"
47 if "is_" in field_name.lower() or field_name.lower() in [
48 "active",
49 "enabled",
50 "published",
51 ]:
52 return "checkbox"
54 # Check options
55 if isinstance(options, list):
56 if len(options) == 2 and {str(o).lower() for o in options} <= {
57 "true",
58 "false",
59 "yes",
60 "no",
61 "1",
62 "0",
63 }:
64 return "checkbox"
65 if len(options) <= 10:
66 return "select"
68 return "text"
70 def render(self) -> Any:
71 if not self.filters:
72 return ""
74 filter_controls = []
76 # If filters is a list, assume they are Filter objects
77 if isinstance(self.filters, list):
78 for f in self.filters:
79 # Check visibility if context available
80 if hasattr(f, "is_visible") and not f.is_visible():
81 continue
83 # Get current value for this filter
84 current_val = self.current_values.get(f.name, f.get_default())
86 # Set state so filters can generate canonical baked hx-vals
87 if self.state and hasattr(f, "set_state"):
88 self.state.set_resource_prefix(self.resource_prefix)
89 f.set_state(self.state)
91 # Schema fields (from lexigram.admin.schema) use render_filter
92 if hasattr(f, "render_filter"):
93 rendered = f.render_filter(current_val)
94 if rendered is not None:
95 filter_controls.append(rendered)
96 # Form fields use bind/render pattern
97 elif hasattr(f, "bind"):
98 bound_f = f.bind(current_val)
99 if self.state and hasattr(bound_f, "set_state"):
100 bound_f.set_state(self.state)
101 filter_controls.append(bound_f.render())
102 # Filter objects (from ui/filters/) use render() with value
103 elif hasattr(f, "render"):
104 if self.state and hasattr(f, "set_state"):
105 f.set_state(self.state)
106 filter_controls.append(
107 f.render(
108 current_val,
109 url=self.resource_prefix.rstrip("/")
110 if self.resource_prefix
111 else None,
112 ),
113 )
114 else:
115 filter_controls.append(
116 el("div", f"Filter {f.name} missing render method"),
117 )
119 return self._wrap_container(filter_controls)
121 # Dict of {field_name: {"type": ..., "options": [...], ...}} format
122 from lexigram.admin.ui.filters.types import SelectFilter, ToggleFilter
124 for field_name, filter_config in self.filters.items():
125 current_val = self.current_values.get(field_name, "")
127 # Create a temporary filter object for rendering
128 if isinstance(filter_config, dict):
129 f_type = filter_config.get("type", "select")
130 options = filter_config.get("options", [])
131 if f_type == "checkbox":
132 f = ToggleFilter(name=field_name)
133 else: # Default to select
134 f = SelectFilter(name=field_name, options=options) # type: ignore[assignment]
135 else:
136 continue
138 # Attach state so filters can include canonical table inputs
139 f.set_state(getattr(self, "state", None))
141 filter_controls.append(
142 f.render(
143 current_val,
144 url=self.resource_prefix.rstrip("/")
145 if self.resource_prefix
146 else None,
147 ),
148 )
150 return self._wrap_container(filter_controls)
152 def _active_filter_count(self) -> int:
153 """Count filter values that are non-empty (active)."""
154 return sum(1 for v in self.current_values.values() if v not in (None, "", []))
156 def _wrap_container(self, filter_controls: list[Any]) -> str:
157 """Helper to wrap controls in container."""
158 if not filter_controls:
159 return ""
161 active_count = self._active_filter_count()
163 # Determine container class based on display preference
164 if self.display == "vertical":
165 container_cls = "flex flex-col gap-2 w-full"
166 else:
167 # horizontal is default: responsive stack on mobile, wrap on desktop
168 container_cls = "flex flex-col md:flex-row md:flex-wrap gap-2 items-stretch md:items-end"
170 # Wrap each control so it is full-width on mobile and auto width on md+ (horizontal) screens
171 wrapped_controls = [
172 el("div", ctrl, class_="w-full md:w-auto") for ctrl in filter_controls
173 ]
175 # Filter icon (funnel) with active count badge
176 filter_icon = el(
177 "svg",
178 el(
179 "path",
180 **{
181 "d": "M4 6h16M4 12h12M4 18h8",
182 "stroke-linecap": "round",
183 "stroke-linejoin": "round",
184 "stroke-width": "2",
185 },
186 ),
187 xmlns="http://www.w3.org/2000/svg",
188 fill="none",
189 viewBox="0 0 24 24",
190 stroke="currentColor",
191 class_="w-5 h-5",
192 )
194 badge: Any = ""
195 if active_count > 0:
196 badge = el(
197 "span",
198 str(active_count),
199 class_="absolute -top-1.5 -right-1.5 bg-primary text-primary-foreground text-[10px] font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 leading-none",
200 )
202 toggle_btn = el(
203 "button",
204 el(
205 "div",
206 filter_icon,
207 badge,
208 class_="relative inline-flex items-center justify-center",
209 ),
210 type="button",
211 class_="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground hover:text-muted-foreground dark:hover:text-foreground",
212 aria_label="Toggle filters",
213 **{"@click": "showFilters = !showFilters"},
214 )
216 return el(
217 "div",
218 el(
219 "div",
220 el(
221 "div",
222 *wrapped_controls,
223 class_=f"{container_cls} flex-1",
224 **{"x-show": "showFilters"},
225 ),
226 el(
227 "div",
228 toggle_btn,
229 class_="flex-shrink-0 flex items-center h-10 md:hidden",
230 ),
231 class_="flex items-start gap-2",
232 ),
233 x_data="{ showFilters: window.innerWidth >= 768 }",
234 class_="bg-card p-3 rounded-xl shadow-sm border border-border mb-1",
235 **self.props,
236 )