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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""Toast notification components for admin layout.
3Renders toast notifications with HTMX support.
4"""
6from __future__ import annotations
8from dataclasses import dataclass, field
9from enum import StrEnum
10from typing import Any
12from markupsafe import escape
14from lexigram.ui.config import ToastConfig
17class ToastType(StrEnum):
18 """Toast notification types."""
20 SUCCESS = "success"
21 ERROR = "error"
22 WARNING = "warning"
23 INFO = "info"
26@dataclass
27class ToastData:
28 """A toast notification message."""
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)
40 def __post_init__(self) -> None:
41 if isinstance(self.type, str):
42 self.type = ToastType(self.type)
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}
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}
62class ServerToastChannel:
63 """Renders toast notification container and messages (server-driven via HTMX)."""
65 def __init__(self, config: ToastConfig | None = None):
66 """Initialize the renderer.
68 Args:
69 config: Toast configuration
70 """
71 self.config = config or ToastConfig()
73 def render(self, toasts: list[ToastData]) -> str:
74 """Render toast payloads as HTML.
76 Args:
77 toasts: List of toast data objects
79 Returns:
80 HTML string for the toast container with toasts
81 """
82 return self.render_container(toasts)
84 def render_container(self, toasts: list[ToastData] | None = None) -> str:
85 """Render the toast container with optional initial toasts.
87 Args:
88 toasts: List of toast messages to show initially
90 Returns:
91 HTML string for toast container
92 """
93 parts: list[str] = []
95 position_classes = self._get_position_classes()
97 parts.append(f"""
98 <div id="toast-container"
99 class="toast-container {position_classes}"
100 aria-live="polite"
101 aria-label="Notifications">
102 """)
104 # Render initial toasts
105 if toasts:
106 for toast in toasts[: self.config.max_toasts]:
107 parts.append(self.render_toast(toast))
109 parts.append("</div>")
111 # Add toast handling script
112 if self.config.listen_for_events:
113 parts.append(self._render_toast_script())
115 return "\n".join(parts)
117 def render_toast(self, toast: ToastData) -> str:
118 """Render a single toast notification.
120 Args:
121 toast: Toast to render
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)}"
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 )
137 parts: list[str] = []
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 """)
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 """)
153 # Content
154 parts.append('<div class="toast-content">')
156 if toast.title:
157 parts.append(f'<div class="toast-title">{escape(toast.title)}</div>')
159 parts.append(f'<div class="toast-message">{escape(toast.message)}</div>')
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>")
174 parts.append("</div>") # toast-content
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 """)
187 parts.append("</div>") # toast
189 return "\n".join(parts)
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")
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;
212 const toast = document.createElement('div');
213 toast.className = 'toast toast-' + (options.type || 'info');
214 toast.setAttribute('role', 'alert');
216 const iconMap = {{
217 success: 'check-circle',
218 error: 'x-circle',
219 warning: 'alert-triangle',
220 info: 'info'
221 }};
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 `;
236 container.appendChild(toast);
238 // Refresh lucide icons
239 if (window.lucide) lucide.createIcons();
241 // Auto dismiss
242 const duration = options.duration || {self.config.default_duration_ms};
243 if (duration > 0) {{
244 setTimeout(() => toast.remove(), duration);
245 }}
246 }}
248 function dismissToast(id) {{
249 const toast = document.getElementById(id);
250 if (toast) toast.remove();
251 }}
253 function escapeHtml(text) {{
254 const div = document.createElement('div');
255 div.textContent = text;
256 return div.innerHTML;
257 }}
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 }});
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 """
282def flash_to_toast(
283 flash_messages: list[tuple[str, str]] | None,
284) -> list[ToastData]:
285 """Convert Flask/Starlette flash messages to toasts.
287 Args:
288 flash_messages: List of (category, message) tuples
290 Returns:
291 List of ToastData objects
292 """
293 if not flash_messages:
294 return []
296 toasts: list[ToastData] = []
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 }
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 )
316 return toasts
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]
330class Toast(ToastData):
331 """Deprecated alias for ToastData. Will be removed in a future release."""
333 def __init__(self, *args: Any, **kwargs: Any) -> None:
334 import warnings
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)
345class ToastRenderer(ServerToastChannel):
346 """Deprecated alias for ServerToastChannel. Will be removed in a future release."""
348 def __init__(self, *args: Any, **kwargs: Any) -> None:
349 import warnings
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)