Coverage for src / lexigram / ui / layouts / server_toasts.py: 83%

93 statements  

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

1"""Toast notification components for admin layout. 

2 

3Renders toast notifications with HTMX support. 

4""" 

5 

6from __future__ import annotations 

7 

8from dataclasses import dataclass, field 

9from enum import StrEnum 

10from typing import Any 

11 

12from markupsafe import escape 

13 

14from lexigram.ui.config import ToastConfig 

15 

16 

17class ToastType(StrEnum): 

18 """Toast notification types.""" 

19 

20 SUCCESS = "success" 

21 ERROR = "error" 

22 WARNING = "warning" 

23 INFO = "info" 

24 

25 

26@dataclass 

27class ToastData: 

28 """A toast notification message.""" 

29 

30 message: str 

31 type: str | ToastType = ToastType.INFO 

32 title: str | None = None 

33 icon: str | None = None 

34 dismissible: bool = True 

35 auto_dismiss: bool = True 

36 duration_ms: int = 5000 

37 id: str | None = None 

38 actions: list[dict[str, str]] = field(default_factory=list) 

39 

40 def __post_init__(self) -> None: 

41 if isinstance(self.type, str): 

42 self.type = ToastType(self.type) 

43 

44 

45# Default icons for toast types 

46DEFAULT_ICONS = { 

47 ToastType.SUCCESS: "check-circle", 

48 ToastType.ERROR: "x-circle", 

49 ToastType.WARNING: "alert-triangle", 

50 ToastType.INFO: "info", 

51} 

52 

53# Default colors for toast types 

54DEFAULT_COLORS = { 

55 ToastType.SUCCESS: "green", 

56 ToastType.ERROR: "red", 

57 ToastType.WARNING: "yellow", 

58 ToastType.INFO: "blue", 

59} 

60 

61 

62class ServerToastChannel: 

63 """Renders toast notification container and messages (server-driven via HTMX).""" 

64 

65 def __init__(self, config: ToastConfig | None = None): 

66 """Initialize the renderer. 

67 

68 Args: 

69 config: Toast configuration 

70 """ 

71 self.config = config or ToastConfig() 

72 

73 def render(self, toasts: list[ToastData]) -> str: 

74 """Render toast payloads as HTML. 

75 

76 Args: 

77 toasts: List of toast data objects 

78 

79 Returns: 

80 HTML string for the toast container with toasts 

81 """ 

82 return self.render_container(toasts) 

83 

84 def render_container(self, toasts: list[ToastData] | None = None) -> str: 

85 """Render the toast container with optional initial toasts. 

86 

87 Args: 

88 toasts: List of toast messages to show initially 

89 

90 Returns: 

91 HTML string for toast container 

92 """ 

93 parts: list[str] = [] 

94 

95 position_classes = self._get_position_classes() 

96 

97 parts.append(f""" 

98 <div id="toast-container" 

99 class="toast-container {position_classes}" 

100 aria-live="polite" 

101 aria-label="Notifications"> 

102 """) 

103 

104 # Render initial toasts 

105 if toasts: 

106 for toast in toasts[: self.config.max_toasts]: 

107 parts.append(self.render_toast(toast)) 

108 

109 parts.append("</div>") 

110 

111 # Add toast handling script 

112 if self.config.listen_for_events: 

113 parts.append(self._render_toast_script()) 

114 

115 return "\n".join(parts) 

116 

117 def render_toast(self, toast: ToastData) -> str: 

118 """Render a single toast notification. 

119 

120 Args: 

121 toast: Toast to render 

122 

123 Returns: 

124 HTML string for toast 

125 """ 

126 toast_type: ToastType = toast.type # type: ignore[assignment] 

127 icon = toast.icon or DEFAULT_ICONS.get(toast_type, "info") 

128 color = DEFAULT_COLORS.get(toast_type, "blue") 

129 toast_id = toast.id or f"toast-{id(toast)}" 

130 

131 auto_dismiss_attrs = "" 

132 if toast.auto_dismiss: 

133 auto_dismiss_attrs = ( 

134 f'data-auto-dismiss="true" data-duration="{toast.duration_ms}"' 

135 ) 

136 

137 parts: list[str] = [] 

138 

139 parts.append(f""" 

140 <div id="{escape(toast_id)}" 

141 class="toast toast-{escape(toast_type.value)} toast-{escape(color)} show" 

142 role="alert" 

143 {auto_dismiss_attrs}> 

144 """) 

145 

146 # Icon 

147 parts.append(f""" 

148 <div class="toast-icon"> 

149 <i data-lucide="{escape(icon)}" class="w-5 h-5"></i> 

150 </div> 

151 """) 

152 

153 # Content 

154 parts.append('<div class="toast-content">') 

155 

156 if toast.title: 

157 parts.append(f'<div class="toast-title">{escape(toast.title)}</div>') 

158 

159 parts.append(f'<div class="toast-message">{escape(toast.message)}</div>') 

160 

161 # Actions 

162 if toast.actions: 

163 parts.append('<div class="toast-actions">') 

164 for action in toast.actions: 

165 parts.append(f""" 

166 <button type="button" 

167 class="toast-action" 

168 onclick="{escape(action.get("onclick", ""))}"> 

169 {escape(action.get("label", "Action"))} 

170 </button> 

171 """) 

172 parts.append("</div>") 

173 

174 parts.append("</div>") # toast-content 

175 

176 # Dismiss button 

177 if toast.dismissible: 

178 parts.append(f""" 

179 <button type="button" 

180 class="toast-dismiss" 

181 onclick="dismissToast('{escape(toast_id)}')" 

182 aria-label="Dismiss notification"> 

183 <i data-lucide="x" class="w-4 h-4"></i> 

184 </button> 

185 """) 

186 

187 parts.append("</div>") # toast 

188 

189 return "\n".join(parts) 

190 

191 def _get_position_classes(self) -> str: 

192 """Get CSS classes for toast position.""" 

193 position_map = { 

194 "top-right": "toast-top toast-right", 

195 "top-left": "toast-top toast-left", 

196 "top-center": "toast-top toast-center", 

197 "bottom-right": "toast-bottom toast-right", 

198 "bottom-left": "toast-bottom toast-left", 

199 "bottom-center": "toast-bottom toast-center", 

200 } 

201 return position_map.get(self.config.position, "toast-top toast-right") 

202 

203 def _render_toast_script(self) -> str: 

204 """Render JavaScript for toast handling.""" 

205 return f""" 

206 <script> 

207 // Toast management 

208 function showToast(options) {{ 

209 const container = document.getElementById('toast-container'); 

210 if (!container) return; 

211 

212 const toast = document.createElement('div'); 

213 toast.className = 'toast toast-' + (options.type || 'info'); 

214 toast.setAttribute('role', 'alert'); 

215 

216 const iconMap = {{ 

217 success: 'check-circle', 

218 error: 'x-circle', 

219 warning: 'alert-triangle', 

220 info: 'info' 

221 }}; 

222 

223 toast.innerHTML = ` 

224 <div class="toast-icon"> 

225 <i data-lucide="${{iconMap[options.type] || 'info'}}" class="w-5 h-5"></i> 

226 </div> 

227 <div class="toast-content"> 

228 ${{options.title ? '<div class="toast-title">' + escapeHtml(options.title) + '</div>' : ''}} 

229 <div class="toast-message">${{escapeHtml(options.message)}}</div> 

230 </div> 

231 <button type="button" class="toast-dismiss" onclick="this.parentElement.remove()"> 

232 <i data-lucide="x" class="w-4 h-4"></i> 

233 </button> 

234 `; 

235 

236 container.appendChild(toast); 

237 

238 // Refresh lucide icons 

239 if (window.lucide) lucide.createIcons(); 

240 

241 // Auto dismiss 

242 const duration = options.duration || {self.config.default_duration_ms}; 

243 if (duration > 0) {{ 

244 setTimeout(() => toast.remove(), duration); 

245 }} 

246 }} 

247 

248 function dismissToast(id) {{ 

249 const toast = document.getElementById(id); 

250 if (toast) toast.remove(); 

251 }} 

252 

253 function escapeHtml(text) {{ 

254 const div = document.createElement('div'); 

255 div.textContent = text; 

256 return div.innerHTML; 

257 }} 

258 

259 // Listen for HTMX events 

260 document.addEventListener('htmx:afterRequest', function(evt) {{ 

261 const xhr = evt.detail.xhr; 

262 const toastHeader = xhr.getResponseHeader('X-Toast'); 

263 if (toastHeader) {{ 

264 try {{ 

265 const toast = JSON.parse(toastHeader); 

266 showToast(toast); 

267 }} catch (e) {{ 

268 console.error('Failed to parse toast header:', e); 

269 }} 

270 }} 

271 }}); 

272 

273 // Auto-dismiss initial toasts 

274 document.querySelectorAll('[data-auto-dismiss="true"]').forEach(toast => {{ 

275 const duration = parseInt(toast.dataset.duration) || {self.config.default_duration_ms}; 

276 setTimeout(() => toast.remove(), duration); 

277 }}); 

278 </script> 

279 """ 

280 

281 

282def flash_to_toast( 

283 flash_messages: list[tuple[str, str]] | None, 

284) -> list[ToastData]: 

285 """Convert Flask/Starlette flash messages to toasts. 

286 

287 Args: 

288 flash_messages: List of (category, message) tuples 

289 

290 Returns: 

291 List of ToastData objects 

292 """ 

293 if not flash_messages: 

294 return [] 

295 

296 toasts: list[ToastData] = [] 

297 

298 category_map = { 

299 "success": ToastType.SUCCESS, 

300 "error": ToastType.ERROR, 

301 "danger": ToastType.ERROR, 

302 "warning": ToastType.WARNING, 

303 "info": ToastType.INFO, 

304 "message": ToastType.INFO, 

305 } 

306 

307 for category, message in flash_messages: 

308 toast_type = category_map.get(category.lower(), ToastType.INFO) 

309 toasts.append( 

310 ToastData( 

311 message=message, 

312 type=toast_type, 

313 ), 

314 ) 

315 

316 return toasts 

317 

318 

319__all__ = [ 

320 "Toast", # deprecated alias for ToastData 

321 "ToastConfig", 

322 "ToastData", 

323 "ToastRenderer", # deprecated alias for ServerToastChannel 

324 "ServerToastChannel", 

325 "ToastType", 

326 "flash_to_toast", 

327] 

328 

329 

330class Toast(ToastData): 

331 """Deprecated alias for ToastData. Will be removed in a future release.""" 

332 

333 def __init__(self, *args: Any, **kwargs: Any) -> None: 

334 import warnings 

335 

336 warnings.warn( 

337 "`Toast` (the toast payload dataclass) is deprecated; use `ToastData` instead. " 

338 "For the inline Alpine component, use `InlineToast`.", 

339 DeprecationWarning, 

340 stacklevel=2, 

341 ) 

342 super().__init__(*args, **kwargs) 

343 

344 

345class ToastRenderer(ServerToastChannel): 

346 """Deprecated alias for ServerToastChannel. Will be removed in a future release.""" 

347 

348 def __init__(self, *args: Any, **kwargs: Any) -> None: 

349 import warnings 

350 

351 warnings.warn( 

352 "`ToastRenderer` is deprecated; use `ServerToastChannel` instead. " 

353 "For inline Alpine toasts, use `InlineToast`.", 

354 DeprecationWarning, 

355 stacklevel=2, 

356 ) 

357 super().__init__(*args, **kwargs)