Coverage for src / lexigram / ui / organisms / slide_over.py: 12%

64 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1from __future__ import annotations 

2 

3import re 

4from typing import Any 

5 

6from lexigram.ui.core.base import Component, el, raw, render_to_string 

7 

8# Width map for size parameter 

9_SIZE_WIDTHS = { 

10 "sm": "max-w-sm", 

11 "md": "max-w-md", 

12 "lg": "max-w-lg", 

13 "xl": "max-w-xl", 

14 "2xl": "max-w-2xl", 

15 "full": "max-w-full", 

16} 

17 

18 

19class SlideOver(Component): 

20 """ 

21 A side-panel drawer component for auxiliary content or editing. 

22 

23 Args: 

24 size: Panel width — ``sm``, ``md``, ``lg`` (default), ``xl``, ``2xl``, ``full`` 

25 variant: ``"default"`` (indigo accent) or ``"danger"`` (red accent for delete confirms) 

26 """ 

27 

28 def __init__( 

29 self, 

30 title: str, 

31 trigger: str | Any = None, 

32 slide_id: str = "slide-over", 

33 is_open: bool = False, 

34 render_trigger: bool = True, 

35 footer: list[Any] | None = None, 

36 size: str = "lg", 

37 variant: str = "default", 

38 subtitle: str | None = None, 

39 **props, 

40 ) -> None: 

41 super().__init__( 

42 title=title, 

43 trigger=trigger, 

44 id=slide_id, 

45 open=is_open, 

46 render_trigger=render_trigger, 

47 footer=footer or [], 

48 **props, 

49 ) 

50 self.title = title 

51 self.trigger = trigger 

52 self.id = slide_id 

53 self.initial_open = is_open 

54 self.render_trigger = render_trigger 

55 self.footer = footer or [] 

56 self.size = size 

57 self.variant = variant # "default" or "danger" 

58 self.subtitle = subtitle 

59 

60 def render(self) -> Any: 

61 rendered_children = list(map(render_to_string, self.children)) 

62 

63 footer_html: list[Any] = [] 

64 footer_found: list[str] = [] 

65 if not self.footer: 

66 new_rendered_children: list[str] = [] 

67 for s in rendered_children: 

68 m = re.search( 

69 r'<div[^>]*(?:data-footer|class="[^"]*(?:actions|slide-over-actions)[^"]*")[^>]*>(.*?)</div>', 

70 s, 

71 re.DOTALL, 

72 ) 

73 if m: 

74 footer_found.append(m.group(1)) 

75 s = s[: m.start()] + s[m.end() :] 

76 new_rendered_children.append(s) 

77 rendered_children = new_rendered_children 

78 

79 if self.footer: 

80 footer_html = [ 

81 raw(render_to_string(c)) 

82 if hasattr(c, "__html__") or hasattr(c, "render") 

83 else str(c) 

84 for c in self.footer 

85 ] 

86 

87 if footer_found and not footer_html: 

88 footer_html = [raw(f) for f in footer_found] 

89 

90 content_html_joined = "".join(rendered_children) 

91 if not footer_html: 

92 form_present = "<form" in content_html_joined.lower() 

93 form_obj = None 

94 for c in self.children: 

95 if hasattr(c, "submit_label"): 

96 form_obj = c 

97 break 

98 

99 form_needs_actions = form_present and ( 

100 form_obj is None or getattr(form_obj, "suppress_submit", False) 

101 ) 

102 

103 if form_needs_actions: 

104 from lexigram.ui.atoms.button import Button, SubmitButton 

105 

106 save_label = getattr(form_obj, "submit_label", "Save") 

107 cancel_btn = Button( 

108 "Cancel", 

109 variant="outline", 

110 x_on_click="open = false", 

111 ) 

112 save_btn = SubmitButton( 

113 label=save_label, 

114 ) 

115 footer_html = [cancel_btn, save_btn] 

116 

117 children_html = [ 

118 raw(s) for s in filter(lambda s: s and s.strip(), rendered_children) 

119 ] 

120 

121 from lexigram.ui.molecules.action_button import ActionButton 

122 

123 # Resolve panel width from size 

124 panel_width = _SIZE_WIDTHS.get(self.size, _SIZE_WIDTHS["lg"]) 

125 

126 # Accent colour based on variant 

127 is_danger = self.variant == "danger" 

128 accent_bar_cls = ( 

129 "h-1 w-full bg-gradient-to-r from-[var(--destructive)] to-[var(--destructive)]/80" 

130 if is_danger 

131 else "h-1 w-full bg-gradient-to-r from-[var(--primary)] to-[var(--primary)]/70" 

132 ) 

133 title_cls = ( 

134 "text-base font-semibold leading-6 text-destructive" 

135 if is_danger 

136 else "text-base font-semibold leading-6 text-foreground" 

137 ) 

138 

139 trigger_node = None 

140 if getattr(self, "render_trigger", True) and self.trigger is not None: 

141 if isinstance(self.trigger, str): 

142 trigger_node = ActionButton( 

143 label=self.trigger, 

144 color="ghost", 

145 size="sm", 

146 type="button", 

147 **{ # type: ignore[arg-type] 

148 "x-on:click": "open = true", 

149 "class_": "text-primary hover:text-primary/80", 

150 }, 

151 ).render() 

152 else: 

153 trigger_node = el( 

154 "div", 

155 self.trigger, 

156 **{"x-on:click": "open = true"}, 

157 class_="inline-block", 

158 tabindex="0", 

159 role="button", 

160 **{"x-on:keydown.enter.prevent": "open = true"}, 

161 **{"x-on:keydown.space.prevent": "open = true"}, 

162 ) 

163 

164 if trigger_node: 

165 trigger_node = el( 

166 "div", 

167 trigger_node, 

168 class_="flex justify-end inline-block", 

169 ) 

170 

171 # Header content 

172 subtitle_el = ( 

173 el( 

174 "p", 

175 {"class": "mt-1 text-xs text-muted-foreground"}, 

176 self.subtitle, 

177 ) 

178 if self.subtitle 

179 else "" 

180 ) 

181 

182 return el( 

183 "div", 

184 { 

185 "x-data": f"{{ open: {'true' if self.initial_open else 'false'} }}", 

186 "class": "relative z-50", 

187 "x-on:keydown.window.escape": "open = false", 

188 "x-effect": "open ? document.body.classList.add('overflow-hidden') : document.body.classList.remove('overflow-hidden')", 

189 "x-cloak": True, 

190 }, 

191 trigger_node if trigger_node else "", 

192 el( 

193 "div", 

194 { 

195 "x-show": "open", 

196 "class": "relative z-50", 

197 "aria-labelledby": "slide-over-title", 

198 "role": "dialog", 

199 "aria-modal": "true", 

200 "x-trap": "open", 

201 }, 

202 # Backdrop 

203 el( 

204 "div", 

205 { 

206 "x-on:click": "open = false", 

207 "x-transition:enter": "ease-in-out duration-300", 

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

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

210 "x-transition:leave": "ease-in-out duration-300", 

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

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

213 "class": "fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity", 

214 }, 

215 ), 

216 el( 

217 "div", 

218 {"class": "fixed inset-0 overflow-hidden"}, 

219 el( 

220 "div", 

221 {"class": "absolute inset-0 overflow-hidden"}, 

222 el( 

223 "div", 

224 { 

225 "class": f"pointer-events-none fixed inset-y-0 right-0 flex {panel_width} w-full", 

226 }, 

227 el( 

228 "div", 

229 { 

230 "x-transition:enter": "transform transition ease-in-out duration-300", 

231 "x-transition:enter-start": "translate-x-full", 

232 "x-transition:enter-end": "translate-x-0", 

233 "x-transition:leave": "transform transition ease-in-out duration-300", 

234 "x-transition:leave-start": "translate-x-0", 

235 "x-transition:leave-end": "translate-x-full", 

236 "class": "pointer-events-auto w-full", 

237 }, 

238 el( 

239 "div", 

240 { 

241 "class": "flex h-full flex-col bg-card shadow-2xl ring-1 ring-border", 

242 }, 

243 # Accent bar at top 

244 el("div", {"class": accent_bar_cls}), 

245 # Header 

246 el( 

247 "div", 

248 {"class": "px-5 py-4 border-b border-border"}, 

249 el( 

250 "div", 

251 { 

252 "class": "flex items-start justify-between gap-3" 

253 }, 

254 el( 

255 "div", 

256 {"class": "min-w-0 flex-1"}, 

257 el( 

258 "h2", 

259 { 

260 "class": title_cls, 

261 "id": "slide-over-title", 

262 }, 

263 self.title, 

264 ), 

265 subtitle_el, 

266 ), 

267 el( 

268 "button", 

269 { 

270 "type": "button", 

271 "x-on:click": "open = false", 

272 "aria-label": "Close panel", 

273 "class": ( 

274 "flex-shrink-0 rounded-lg p-1.5 text-muted-foreground " 

275 "hover:bg-accent hover:text-accent-foreground " 

276 "transition-colors focus:outline-none " 

277 "focus-visible:ring-2 focus-visible:ring-ring" 

278 ), 

279 }, 

280 raw( 

281 '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" ' 

282 'stroke-width="1.5" stroke="currentColor">' 

283 '<path stroke-linecap="round" stroke-linejoin="round" ' 

284 'd="M6 18L18 6M6 6l12 12"/>' 

285 "</svg>" 

286 ), 

287 ), 

288 ), 

289 ), 

290 # Scrollable content 

291 el( 

292 "div", 

293 { 

294 "class": "relative flex-1 overflow-y-auto px-5 py-5", 

295 }, 

296 *children_html, 

297 ), 

298 # Sticky footer 

299 ( 

300 el( 

301 "div", 

302 { 

303 "class": ( 

304 "border-t border-border " 

305 "bg-muted " 

306 "px-5 py-4 flex items-center justify-end gap-3 " 

307 "sticky bottom-0" 

308 ), 

309 }, 

310 *footer_html, 

311 ) 

312 if footer_html 

313 else "" 

314 ), 

315 ), 

316 ), 

317 ), 

318 ), 

319 ), 

320 ), 

321 )