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

1from __future__ import annotations 

2 

3import re 

4from typing import Any 

5 

6from lexigram.ui.atoms.button import Button, ButtonVariant, SubmitButton 

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

8 

9 

10class Modal(Component): 

11 """ 

12 A FAANG-level modal dialog powered by Alpine.js. 

13 """ 

14 

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 ) 

22 

23 # Backdrop constants 

24 BACKDROP_CLASSES = "fixed inset-0 bg-background/80 transition-opacity" 

25 

26 # Transition timing 

27 TRANSITION_ENTER = "ease-out duration-300" 

28 TRANSITION_LEAVE = "ease-in duration-200" 

29 

30 # Default button variants 

31 DEFAULT_CANCEL_VARIANT: ButtonVariant = "outline" 

32 DEFAULT_CREATE_VARIANT: ButtonVariant = "default" 

33 

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 

60 

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" 

64 

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 ) 

84 

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

86 

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 ] 

97 

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 

112 

113 if footer_found and not footer_html: 

114 footer_html = list(map(raw, footer_found)) 

115 

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] 

134 

135 children_html = list( 

136 map(raw, filter(lambda s: s and s.strip(), rendered_children)), 

137 ) 

138 

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 )