toast.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. """Sonner toast component."""
  2. from __future__ import annotations
  3. from typing import Any, Literal
  4. from reflex.base import Base
  5. from reflex.components.component import Component, ComponentNamespace
  6. from reflex.components.lucide.icon import Icon
  7. from reflex.components.props import NoExtrasAllowedProps, PropsBase
  8. from reflex.constants.base import Dirs
  9. from reflex.event import EventSpec, run_script
  10. from reflex.style import Style, resolved_color_mode
  11. from reflex.utils import format
  12. from reflex.utils.imports import ImportVar
  13. from reflex.utils.serializers import serializer
  14. from reflex.vars import VarData
  15. from reflex.vars.base import LiteralVar, Var
  16. from reflex.vars.function import FunctionVar
  17. from reflex.vars.number import ternary_operation
  18. from reflex.vars.object import ObjectVar
  19. LiteralPosition = Literal[
  20. "top-left",
  21. "top-center",
  22. "top-right",
  23. "bottom-left",
  24. "bottom-center",
  25. "bottom-right",
  26. ]
  27. toast_ref = Var(
  28. _js_expr="refs['__toast']",
  29. _var_data=VarData(imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}),
  30. )
  31. class ToastAction(Base):
  32. """A toast action that render a button in the toast."""
  33. label: str
  34. on_click: Any
  35. @serializer
  36. def serialize_action(action: ToastAction) -> dict:
  37. """Serialize a toast action.
  38. Args:
  39. action: The toast action to serialize.
  40. Returns:
  41. The serialized toast action with on_click formatted to queue the given event.
  42. """
  43. return {
  44. "label": action.label,
  45. "onClick": format.format_queue_events(action.on_click),
  46. }
  47. def _toast_callback_signature(toast: Var) -> list[Var]:
  48. """The signature for the toast callback, stripping out unserializable keys.
  49. Args:
  50. toast: The toast variable.
  51. Returns:
  52. A function call stripping non-serializable members of the toast object.
  53. """
  54. return [
  55. Var(
  56. _js_expr=f"(() => {{let {{action, cancel, onDismiss, onAutoClose, ...rest}} = {toast!s}; return rest}})()"
  57. )
  58. ]
  59. class ToastProps(PropsBase, NoExtrasAllowedProps):
  60. """Props for the toast component."""
  61. # Toast's title, renders above the description.
  62. title: str | Var | None
  63. # Toast's description, renders underneath the title.
  64. description: str | Var | None
  65. # Whether to show the close button.
  66. close_button: bool | None
  67. # Dark toast in light mode and vice versa.
  68. invert: bool | None
  69. # Control the sensitivity of the toast for screen readers
  70. important: bool | None
  71. # Time in milliseconds that should elapse before automatically closing the toast.
  72. duration: int | None
  73. # Position of the toast.
  74. position: LiteralPosition | None
  75. # If false, it'll prevent the user from dismissing the toast.
  76. dismissible: bool | None
  77. # TODO: fix serialization of icons for toast? (might not be possible yet)
  78. # Icon displayed in front of toast's text, aligned vertically.
  79. # icon: Icon | None = None # noqa: ERA001
  80. # TODO: fix implementation for action / cancel buttons
  81. # Renders a primary button, clicking it will close the toast.
  82. action: ToastAction | None
  83. # Renders a secondary button, clicking it will close the toast.
  84. cancel: ToastAction | None
  85. # Custom id for the toast.
  86. id: str | Var | None
  87. # Removes the default styling, which allows for easier customization.
  88. unstyled: bool | None
  89. # Custom style for the toast.
  90. style: Style | None
  91. # Class name for the toast.
  92. class_name: str | None
  93. # XXX: These still do not seem to work
  94. # Custom style for the toast primary button.
  95. action_button_styles: Style | None
  96. # Custom style for the toast secondary button.
  97. cancel_button_styles: Style | None
  98. # The function gets called when either the close button is clicked, or the toast is swiped.
  99. on_dismiss: Any | None
  100. # Function that gets called when the toast disappears automatically after it's timeout (duration` prop).
  101. on_auto_close: Any | None
  102. def dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
  103. """Convert the object to a dictionary.
  104. Args:
  105. *args: The arguments to pass to the base class.
  106. **kwargs: The keyword arguments to pass to the base
  107. Returns:
  108. The object as a dictionary with ToastAction fields intact.
  109. """
  110. kwargs.setdefault("exclude_none", True)
  111. d = super().dict(*args, **kwargs)
  112. # Keep these fields as ToastAction so they can be serialized specially
  113. if "action" in d:
  114. d["action"] = self.action
  115. if isinstance(self.action, dict):
  116. d["action"] = ToastAction(**self.action)
  117. if "cancel" in d:
  118. d["cancel"] = self.cancel
  119. if isinstance(self.cancel, dict):
  120. d["cancel"] = ToastAction(**self.cancel)
  121. if "onDismiss" in d:
  122. d["onDismiss"] = format.format_queue_events(
  123. self.on_dismiss, _toast_callback_signature
  124. )
  125. if "onAutoClose" in d:
  126. d["onAutoClose"] = format.format_queue_events(
  127. self.on_auto_close, _toast_callback_signature
  128. )
  129. return d
  130. class Toaster(Component):
  131. """A Toaster Component for displaying toast notifications."""
  132. library: str | None = "sonner@2.0.3"
  133. tag = "Toaster"
  134. # the theme of the toast
  135. theme: Var[str] = resolved_color_mode
  136. # whether to show rich colors
  137. rich_colors: Var[bool] = LiteralVar.create(True)
  138. # whether to expand the toast
  139. expand: Var[bool] = LiteralVar.create(True)
  140. # the number of toasts that are currently visible
  141. visible_toasts: Var[int]
  142. # the position of the toast
  143. position: Var[LiteralPosition] = LiteralVar.create("bottom-right")
  144. # whether to show the close button
  145. close_button: Var[bool] = LiteralVar.create(False)
  146. # offset of the toast
  147. offset: Var[str]
  148. # directionality of the toast (default: ltr)
  149. dir: Var[str]
  150. # Keyboard shortcut that will move focus to the toaster area.
  151. hotkey: Var[str]
  152. # Dark toasts in light mode and vice versa.
  153. invert: Var[bool]
  154. # These will act as default options for all toasts. See toast() for all available options.
  155. toast_options: Var[ToastProps]
  156. # Gap between toasts when expanded
  157. gap: Var[int]
  158. # Changes the default loading icon
  159. loading_icon: Var[Icon]
  160. # Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked.
  161. pause_when_page_is_hidden: Var[bool]
  162. def add_hooks(self) -> list[Var | str]:
  163. """Add hooks for the toaster component.
  164. Returns:
  165. The hooks for the toaster component.
  166. """
  167. if self.library is None:
  168. return []
  169. hook = Var(
  170. _js_expr=f"{toast_ref} = toast",
  171. _var_data=VarData(
  172. imports={
  173. "$/utils/state": [ImportVar(tag="refs")],
  174. self.library: [ImportVar(tag="toast", install=False)],
  175. }
  176. ),
  177. )
  178. return [hook]
  179. @staticmethod
  180. def send_toast(
  181. message: str | Var = "",
  182. level: str | None = None,
  183. fallback_to_alert: bool = False,
  184. **props,
  185. ) -> EventSpec:
  186. """Send a toast message.
  187. Args:
  188. message: The message to display.
  189. level: The level of the toast.
  190. fallback_to_alert: Whether to fallback to an alert if the toaster is not created.
  191. **props: The options for the toast.
  192. Raises:
  193. ValueError: If the Toaster component is not created.
  194. Returns:
  195. The toast event.
  196. """
  197. toast_command = (
  198. ObjectVar.__getattr__(toast_ref.to(dict), level) if level else toast_ref
  199. ).to(FunctionVar)
  200. if isinstance(message, Var):
  201. props.setdefault("title", message)
  202. message = ""
  203. elif message == "" and "title" not in props and "description" not in props:
  204. raise ValueError("Toast message or title or description must be provided.")
  205. if props:
  206. args = LiteralVar.create(ToastProps(component_name="rx.toast", **props)) # pyright: ignore [reportCallIssue]
  207. toast = toast_command.call(message, args)
  208. else:
  209. toast = toast_command.call(message)
  210. if fallback_to_alert:
  211. toast = ternary_operation(
  212. toast_ref.bool(),
  213. toast,
  214. FunctionVar("window.alert").call(
  215. Var.create(
  216. message
  217. if isinstance(message, str) and message
  218. else props.get("title", props.get("description", ""))
  219. )
  220. .to(str)
  221. .replace("<br/>", "\n")
  222. ),
  223. )
  224. return run_script(toast)
  225. @staticmethod
  226. def toast_info(message: str | Var = "", **kwargs: Any):
  227. """Display an info toast message.
  228. Args:
  229. message: The message to display.
  230. **kwargs: Additional toast props.
  231. Returns:
  232. The toast event.
  233. """
  234. return Toaster.send_toast(message, level="info", **kwargs)
  235. @staticmethod
  236. def toast_warning(message: str | Var = "", **kwargs: Any):
  237. """Display a warning toast message.
  238. Args:
  239. message: The message to display.
  240. **kwargs: Additional toast props.
  241. Returns:
  242. The toast event.
  243. """
  244. return Toaster.send_toast(message, level="warning", **kwargs)
  245. @staticmethod
  246. def toast_error(message: str | Var = "", **kwargs: Any):
  247. """Display an error toast message.
  248. Args:
  249. message: The message to display.
  250. **kwargs: Additional toast props.
  251. Returns:
  252. The toast event.
  253. """
  254. return Toaster.send_toast(message, level="error", **kwargs)
  255. @staticmethod
  256. def toast_success(message: str | Var = "", **kwargs: Any):
  257. """Display a success toast message.
  258. Args:
  259. message: The message to display.
  260. **kwargs: Additional toast props.
  261. Returns:
  262. The toast event.
  263. """
  264. return Toaster.send_toast(message, level="success", **kwargs)
  265. @staticmethod
  266. def toast_loading(message: str | Var = "", **kwargs: Any):
  267. """Display a loading toast message.
  268. Args:
  269. message: The message to display.
  270. **kwargs: Additional toast props.
  271. Returns:
  272. The toast event.
  273. """
  274. return Toaster.send_toast(message, level="loading", **kwargs)
  275. @staticmethod
  276. def toast_dismiss(id: Var | str | None = None):
  277. """Dismiss a toast.
  278. Args:
  279. id: The id of the toast to dismiss.
  280. Returns:
  281. The toast dismiss event.
  282. """
  283. dismiss_var_data = None
  284. if isinstance(id, Var):
  285. dismiss = f"{toast_ref}.dismiss({id!s})"
  286. dismiss_var_data = id._get_all_var_data()
  287. elif isinstance(id, str):
  288. dismiss = f"{toast_ref}.dismiss('{id}')"
  289. else:
  290. dismiss = f"{toast_ref}.dismiss()"
  291. dismiss_action = Var(
  292. _js_expr=dismiss, _var_data=VarData.merge(dismiss_var_data)
  293. )
  294. return run_script(dismiss_action)
  295. @classmethod
  296. def create(cls, *children: Any, **props: Any) -> Component:
  297. """Create a toaster component.
  298. Args:
  299. *children: The children of the toaster.
  300. **props: The properties of the toaster.
  301. Returns:
  302. The toaster component.
  303. """
  304. return super().create(*children, **props)
  305. # TODO: figure out why loading toast stay open forever when using level="loading" in toast()
  306. class ToastNamespace(ComponentNamespace):
  307. """Namespace for toast components."""
  308. provider = staticmethod(Toaster.create)
  309. options = staticmethod(ToastProps)
  310. info = staticmethod(Toaster.toast_info)
  311. warning = staticmethod(Toaster.toast_warning)
  312. error = staticmethod(Toaster.toast_error)
  313. success = staticmethod(Toaster.toast_success)
  314. loading = staticmethod(Toaster.toast_loading)
  315. dismiss = staticmethod(Toaster.toast_dismiss)
  316. __call__ = staticmethod(Toaster.send_toast)
  317. toast = ToastNamespace()