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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3import re
4from typing import Any
6from lexigram.ui.core.base import Component, el, raw, render_to_string
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}
19class SlideOver(Component):
20 """
21 A side-panel drawer component for auxiliary content or editing.
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 """
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
60 def render(self) -> Any:
61 rendered_children = list(map(render_to_string, self.children))
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
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 ]
87 if footer_found and not footer_html:
88 footer_html = [raw(f) for f in footer_found]
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
99 form_needs_actions = form_present and (
100 form_obj is None or getattr(form_obj, "suppress_submit", False)
101 )
103 if form_needs_actions:
104 from lexigram.ui.atoms.button import Button, SubmitButton
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]
117 children_html = [
118 raw(s) for s in filter(lambda s: s and s.strip(), rendered_children)
119 ]
121 from lexigram.ui.molecules.action_button import ActionButton
123 # Resolve panel width from size
124 panel_width = _SIZE_WIDTHS.get(self.size, _SIZE_WIDTHS["lg"])
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 )
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 )
164 if trigger_node:
165 trigger_node = el(
166 "div",
167 trigger_node,
168 class_="flex justify-end inline-block",
169 )
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 )
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 )