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

1"""Stacked/sidebar filter drawer — slide-over panel with all filters. 

2 

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

9 

10from __future__ import annotations 

11 

12from typing import Any 

13 

14from lexigram.ui import Component, Zones, el 

15 

16 

17class FilterDrawer(Component): 

18 """Slide-over filter panel with stacked filter controls. 

19 

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. 

24 

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

32 

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 

46 

47 # ------------------------------------------------------------------ 

48 # Public render 

49 # ------------------------------------------------------------------ 

50 

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

56 

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 ) 

72 

73 # ------------------------------------------------------------------ 

74 # Private helpers 

75 # ------------------------------------------------------------------ 

76 

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 ) 

82 

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 ) 

95 

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 ) 

129 

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 ) 

147 

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

152 

153 res_prefix = (self.resource_prefix or "").strip("/").replace("/", "_") 

154 if not res_prefix: 

155 res_prefix = "global" 

156 

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 } 

175 

176 reset_href = apply_url 

177 if self.resource_prefix: 

178 reset_href = apply_url # clear all → GET without filter params 

179 

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 ) 

279 

280 def _build_filter_controls(self) -> list[Any]: 

281 """Build stacked filter controls for the panel.""" 

282 controls: list[Any] = [] 

283 

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 ) 

345 

346 return controls