Coverage for src / lexigram / ui / molecules / modal.py: 82%
55 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.atoms.button import Button, ButtonVariant, SubmitButton
7from lexigram.ui.core.base import Component, el, raw, render_to_string
10class Modal(Component):
11 """
12 A FAANG-level modal dialog powered by Alpine.js.
13 """
15 # Modal panel styling constants
16 PANEL_MAX_WIDTH = "max-w-[33.333vw]"
17 PANEL_MAX_HEIGHT = "max-h-[66vh]"
18 PANEL_CLASSES = (
19 "relative transform overflow-hidden rounded-lg bg-card "
20 "text-left shadow-xl transition-all w-full"
21 )
23 # Backdrop constants
24 BACKDROP_CLASSES = "fixed inset-0 bg-background/80 transition-opacity"
26 # Transition timing
27 TRANSITION_ENTER = "ease-out duration-300"
28 TRANSITION_LEAVE = "ease-in duration-200"
30 # Default button variants
31 DEFAULT_CANCEL_VARIANT: ButtonVariant = "outline"
32 DEFAULT_CREATE_VARIANT: ButtonVariant = "default"
34 def __init__(
35 self,
36 title: str,
37 trigger: str | Any = None,
38 footer: list[Any] | None = None,
39 is_open: bool = False,
40 render_trigger: bool = True,
41 max_width: str | None = None,
42 max_height: str | None = None,
43 **props,
44 ) -> None:
45 super().__init__(
46 title=title,
47 trigger=trigger,
48 footer=footer or [],
49 open=is_open,
50 render_trigger=render_trigger,
51 **props,
52 )
53 self.title = title
54 self.trigger = trigger
55 self.footer = footer or []
56 self.initial_open = is_open
57 self.render_trigger = render_trigger
58 self.max_width = max_width or self.PANEL_MAX_WIDTH
59 self.max_height = max_height or self.PANEL_MAX_HEIGHT
61 def _build_panel_classes(self) -> str:
62 """Build the panel classes with configurable max width/height."""
63 return f"{self.PANEL_CLASSES} {self.max_width} {self.max_height} flex flex-col"
65 def render(self) -> Any:
66 trigger_node = None
67 if getattr(self, "render_trigger", True) and self.trigger is not None:
68 if isinstance(self.trigger, str):
69 trigger_node = Button(
70 self.trigger,
71 x_on_click="open = true",
72 )
73 else:
74 trigger_node = el(
75 "div",
76 self.trigger,
77 **{"x-on:click": "open = true"},
78 class_="inline-block cursor-pointer",
79 tabindex="0",
80 role="button",
81 **{"x-on:keydown.enter.prevent": "open = true"},
82 **{"x-on:keydown.space.prevent": "open = true"},
83 )
85 rendered_children = list(map(render_to_string, self.children))
87 footer_html: list[Any] = []
88 if self.footer:
89 footer_html = [
90 (
91 raw(render_to_string(c))
92 if hasattr(c, "__html__") or hasattr(c, "render")
93 else str(c)
94 )
95 for c in self.footer
96 ]
98 footer_found: list[str] = []
99 if not footer_html:
100 new_rendered_children: list[str] = []
101 for s in rendered_children:
102 m = re.search(
103 r'<div[^>]*(?:data-footer|class="[^"]*(?:actions|slide-over-actions)[^"]*")[^>]*>(.*?)</div>',
104 s,
105 re.DOTALL,
106 )
107 if m:
108 footer_found.append(m.group(1))
109 s = s[: m.start()] + s[m.end() :]
110 new_rendered_children.append(s)
111 rendered_children = new_rendered_children
113 if footer_found and not footer_html:
114 footer_html = list(map(raw, footer_found))
116 if not footer_html:
117 form_present = any(
118 (
119 (getattr(getattr(c, "__class__", None), "__name__", "") == "Form")
120 for c in self.children
121 ),
122 )
123 if form_present:
124 cancel_btn = Button(
125 "Cancel",
126 variant=self.DEFAULT_CANCEL_VARIANT,
127 x_on_click="open = false",
128 )
129 create_btn = SubmitButton(
130 label="Create",
131 variant=self.DEFAULT_CREATE_VARIANT,
132 )
133 footer_html = [cancel_btn, create_btn]
135 children_html = list(
136 map(raw, filter(lambda s: s and s.strip(), rendered_children)),
137 )
139 return el(
140 "div",
141 {
142 "x-data": f"{{ open: {'true' if self.initial_open else 'false'} }}",
143 "class": "relative z-50",
144 "x-on:keydown.window.escape": "open = false",
145 "x-effect": "open ? document.body.classList.add('overflow-hidden') : document.body.classList.remove('overflow-hidden')",
146 "x-cloak": True,
147 },
148 trigger_node if trigger_node else "",
149 el(
150 "div",
151 {
152 "x-show": "open",
153 "x-transition:enter": self.TRANSITION_ENTER,
154 "x-transition:leave": self.TRANSITION_LEAVE,
155 "class": "fixed inset-0 z-50",
156 },
157 el(
158 "div",
159 {
160 "x-on:click": "open = false",
161 "x-transition:enter": self.TRANSITION_ENTER,
162 "x-transition:enter-start": "opacity-0",
163 "x-transition:enter-end": "opacity-100",
164 "x-transition:leave": self.TRANSITION_LEAVE,
165 "x-transition:leave-start": "opacity-100",
166 "x-transition:leave-end": "opacity-0",
167 "class": self.BACKDROP_CLASSES,
168 "aria_hidden": "true",
169 },
170 ),
171 el(
172 "div",
173 {
174 "class": "fixed inset-0 z-10 w-screen overflow-y-auto",
175 "role": "dialog",
176 "aria-modal": "true",
177 "aria-labelledby": "modal-title",
178 },
179 el(
180 "div",
181 {
182 "class": "flex min-h-screen items-center justify-center p-4 text-center sm:p-0",
183 },
184 el(
185 "div",
186 {
187 "x-trap": "open",
188 "x-transition:enter": self.TRANSITION_ENTER,
189 "x-transition:enter-start": "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
190 "x-transition:enter-end": "opacity-100 translate-y-0 sm:scale-100",
191 "x-transition:leave": self.TRANSITION_LEAVE,
192 "x-transition:leave-start": "opacity-100 translate-y-0 sm:scale-100",
193 "x-transition:leave-end": "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
194 "class": self._build_panel_classes(),
195 },
196 # Header (keeps title)
197 el(
198 "div",
199 {"class": "px-4 pb-0 pt-5 sm:p-6 sm:pb-0"},
200 el(
201 "div",
202 {"class": "sm:flex sm:items-start"},
203 el(
204 "div",
205 {
206 "class": "mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full",
207 },
208 el(
209 "h3",
210 {
211 "class": "text-base font-semibold leading-6 text-foreground",
212 "id": "modal-title",
213 },
214 self.title,
215 ),
216 ),
217 ),
218 ),
219 # Scrollable Content
220 el(
221 "div",
222 {
223 "class": "relative mt-2 flex-1 overflow-y-auto px-4 sm:px-6",
224 },
225 *children_html,
226 ),
227 # Sticky Footer (no default buttons; caller supplies footer). Add gap between buttons
228 el(
229 "div",
230 {
231 "class": "bg-muted px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 sticky bottom-0 gap-3 items-center",
232 },
233 *footer_html,
234 ),
235 ),
236 ),
237 ),
238 ),
239 )