Coverage for src / lexigram / ui / molecules / toast.py: 97%

32 statements  

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

1"""Toast notification — inline Alpine.js component and shared types. 

2 

3This module provides the inline ``InlineToast`` Component (Alpine.js show/hide) 

4and re-exports the server-driven toast system from the layout layer 

5so consumers have a single import path. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import Any 

11 

12from lexigram.ui.core.base import Component, el 

13from lexigram.ui.layouts.server_toasts import ( 

14 ServerToastChannel, 

15 ToastData, 

16 ToastRenderer, 

17 ToastType, 

18 flash_to_toast, 

19) 

20from lexigram.ui.styles import get_semantic_icon, get_toast_classes 

21 

22__all__ = [ 

23 "InlineToast", 

24 "Toast", # deprecated alias for InlineToast 

25 "ToastData", 

26 "ServerToastChannel", 

27 "ToastRenderer", # deprecated alias for ServerToastChannel 

28 "ToastType", 

29 "flash_to_toast", 

30] 

31 

32 

33class InlineToast(Component): 

34 """Toast notification component with Alpine.js auto-dismiss and optional action. 

35 

36 For server-driven toasts (HTMX ``X-Toast`` headers, configurable position, 

37 stacking), use ``ServerToastChannel`` with ``ToastData`` instead. 

38 

39 Args: 

40 message: Notification message. 

41 toast_type: Severity — ``"info"``, ``"success"``, ``"warning"``, ``"error"``. 

42 duration: Auto-dismiss delay in milliseconds (default 3000). 

43 action_label: Optional label for an inline action button. 

44 action_url: URL the action button links to (``href`` when set). 

45 """ 

46 

47 def __init__( 

48 self, 

49 message: str, 

50 toast_type: str = "info", 

51 duration: int = 3000, 

52 action_label: str = "", 

53 action_url: str = "", 

54 **props: Any, 

55 ) -> None: 

56 super().__init__( 

57 message=message, 

58 type=toast_type, 

59 duration=duration, 

60 action_label=action_label, 

61 action_url=action_url, 

62 **props, 

63 ) 

64 self.message = message 

65 self.type = toast_type 

66 self.duration = duration 

67 self.action_label = action_label 

68 self.action_url = action_url 

69 

70 def render(self) -> Any: 

71 from lexigram.ui.atoms.icons import get_icon 

72 

73 bg_color = get_toast_classes(self.type) 

74 icon_name = get_semantic_icon(self.type) 

75 

76 inner: list[Any] = [ 

77 el( 

78 "div", 

79 get_icon(icon_name, class_name="w-5 h-5"), 

80 class_="mr-3 flex-shrink-0", 

81 ), 

82 el("div", self.message, class_="font-medium flex-1"), 

83 ] 

84 

85 if self.action_label: 

86 action_attrs: dict[str, Any] = { 

87 "class_": "ml-4 text-sm font-semibold underline underline-offset-2 hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 rounded", 

88 } 

89 if self.action_url: 

90 action_el = el( 

91 "a", self.action_label, href=self.action_url, **action_attrs 

92 ) 

93 else: 

94 action_el = el( 

95 "button", self.action_label, type="button", **action_attrs 

96 ) 

97 inner.append(action_el) 

98 

99 inner.append( 

100 el( 

101 "button", 

102 "✕", 

103 type="button", 

104 aria_label="Close", 

105 **{ 

106 "class_": "ml-3 flex-shrink-0 opacity-70 hover:opacity-100 focus:outline-none", 

107 "@click": "show = false", 

108 }, 

109 ), 

110 ) 

111 

112 return el( 

113 "div", 

114 *inner, 

115 role="status", 

116 class_=f"fixed bottom-4 right-4 {bg_color} text-white px-4 py-3 rounded-lg shadow-lg z-50 transition-all duration-300 transform flex items-center max-w-sm", 

117 **{ 

118 "x-data": "{ show: true }", 

119 "x-show": "show", 

120 "x-init": f"setTimeout(() => show = false, {self.duration})", 

121 "x-transition:enter": "transition ease-out duration-300", 

122 "x-transition:enter-start": "opacity-0 translate-y-2", 

123 "x-transition:enter-end": "opacity-100 translate-y-0", 

124 "x-transition:leave": "transition ease-in duration-200", 

125 "x-transition:leave-start": "opacity-100 translate-y-0", 

126 "x-transition:leave-end": "opacity-0 translate-y-2", 

127 }, 

128 aria_live="polite", 

129 ) 

130 

131 

132class Toast(InlineToast): 

133 """Deprecated alias for InlineToast. Will be removed in a future release.""" 

134 

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

136 import warnings 

137 

138 warnings.warn( 

139 "`Toast` (the Alpine inline-notification Component) is deprecated; " 

140 "use `InlineToast` instead. For the toast payload dataclass, use `ToastData`. " 

141 "For server-driven toasts, use `ServerToastChannel`.", 

142 DeprecationWarning, 

143 stacklevel=2, 

144 ) 

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