417 lines
13 KiB
Python
417 lines
13 KiB
Python
"""Sonner toast component."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from typing import Any, Literal
|
|
|
|
from reflex_base.components.component import Component, ComponentNamespace, field
|
|
from reflex_base.components.props import NoExtrasAllowedProps
|
|
from reflex_base.constants.base import Dirs
|
|
from reflex_base.event import EventSpec, run_script
|
|
from reflex_base.style import Style, resolved_color_mode
|
|
from reflex_base.utils import format
|
|
from reflex_base.utils.imports import ImportVar
|
|
from reflex_base.utils.serializers import serializer
|
|
from reflex_base.vars import VarData
|
|
from reflex_base.vars.base import LiteralVar, Var
|
|
from reflex_base.vars.function import FunctionVar
|
|
from reflex_base.vars.number import ternary_operation
|
|
from reflex_base.vars.object import ObjectVar
|
|
from reflex_components_lucide.icon import Icon
|
|
|
|
LiteralPosition = Literal[
|
|
"top-left",
|
|
"top-center",
|
|
"top-right",
|
|
"bottom-left",
|
|
"bottom-center",
|
|
"bottom-right",
|
|
]
|
|
|
|
toast_ref = Var(
|
|
_js_expr="refs['__toast']",
|
|
_var_data=VarData(imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}),
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class ToastAction:
|
|
"""A toast action that render a button in the toast."""
|
|
|
|
label: str
|
|
on_click: Any
|
|
|
|
|
|
@serializer
|
|
def serialize_action(action: ToastAction) -> dict:
|
|
"""Serialize a toast action.
|
|
|
|
Args:
|
|
action: The toast action to serialize.
|
|
|
|
Returns:
|
|
The serialized toast action with on_click formatted to queue the given event.
|
|
"""
|
|
return {
|
|
"label": action.label,
|
|
"onClick": format.format_queue_events(action.on_click),
|
|
}
|
|
|
|
|
|
def _toast_callback_signature(toast: Var) -> list[Var]:
|
|
"""The signature for the toast callback, stripping out unserializable keys.
|
|
|
|
Args:
|
|
toast: The toast variable.
|
|
|
|
Returns:
|
|
A function call stripping non-serializable members of the toast object.
|
|
"""
|
|
return [
|
|
Var(
|
|
_js_expr=f"(() => {{let {{action, cancel, onDismiss, onAutoClose, ...rest}} = {toast!s}; return rest}})()"
|
|
)
|
|
]
|
|
|
|
|
|
class ToastProps(NoExtrasAllowedProps):
|
|
"""Props for the toast component."""
|
|
|
|
# Toast's title, renders above the description.
|
|
title: str | Var | None
|
|
|
|
# Toast's description, renders underneath the title.
|
|
description: str | Var | None
|
|
|
|
# Whether to show the close button.
|
|
close_button: bool | None
|
|
|
|
# Dark toast in light mode and vice versa.
|
|
invert: bool | None
|
|
|
|
# Control the sensitivity of the toast for screen readers
|
|
important: bool | None
|
|
|
|
# Time in milliseconds that should elapse before automatically closing the toast.
|
|
duration: int | None
|
|
|
|
# Position of the toast.
|
|
position: LiteralPosition | None
|
|
|
|
# If false, it'll prevent the user from dismissing the toast.
|
|
dismissible: bool | None
|
|
|
|
# TODO: fix serialization of icons for toast? (might not be possible yet)
|
|
# Icon displayed in front of toast's text, aligned vertically.
|
|
# icon: Icon | None = None # noqa: ERA001
|
|
|
|
# TODO: fix implementation for action / cancel buttons
|
|
# Renders a primary button, clicking it will close the toast.
|
|
action: ToastAction | None
|
|
|
|
# Renders a secondary button, clicking it will close the toast.
|
|
cancel: ToastAction | None
|
|
|
|
# Custom id for the toast.
|
|
id: str | Var | None
|
|
|
|
# Removes the default styling, which allows for easier customization.
|
|
unstyled: bool | None
|
|
|
|
# Custom style for the toast.
|
|
style: Style | None
|
|
|
|
# Class name for the toast.
|
|
class_name: str | None
|
|
|
|
# XXX: These still do not seem to work Custom style for the toast primary button.
|
|
action_button_styles: Style | None
|
|
|
|
# Custom style for the toast secondary button.
|
|
cancel_button_styles: Style | None
|
|
|
|
# The function gets called when either the close button is clicked, or the toast is swiped.
|
|
on_dismiss: Any | None
|
|
|
|
# Function that gets called when the toast disappears automatically after it's timeout (duration` prop).
|
|
on_auto_close: Any | None
|
|
|
|
def dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
"""Convert the object to a dictionary.
|
|
|
|
Args:
|
|
*args: The arguments to pass to the base class.
|
|
**kwargs: The keyword arguments to pass to the base
|
|
|
|
Returns:
|
|
The object as a dictionary with ToastAction fields intact.
|
|
"""
|
|
d = super().dict(*args, **kwargs)
|
|
# Keep these fields as ToastAction so they can be serialized specially
|
|
if "action" in d:
|
|
d["action"] = self.action
|
|
if isinstance(self.action, dict):
|
|
d["action"] = ToastAction(**self.action)
|
|
if "cancel" in d:
|
|
d["cancel"] = self.cancel
|
|
if isinstance(self.cancel, dict):
|
|
d["cancel"] = ToastAction(**self.cancel)
|
|
if "onDismiss" in d:
|
|
d["onDismiss"] = format.format_queue_events(
|
|
self.on_dismiss, _toast_callback_signature
|
|
)
|
|
if "onAutoClose" in d:
|
|
d["onAutoClose"] = format.format_queue_events(
|
|
self.on_auto_close, _toast_callback_signature
|
|
)
|
|
return d
|
|
|
|
|
|
class Toaster(Component):
|
|
"""A Toaster Component for displaying toast notifications."""
|
|
|
|
library: str | None = "sonner@2.0.7"
|
|
|
|
tag = "Toaster"
|
|
|
|
theme: Var[str] = field(default=resolved_color_mode, doc="the theme of the toast")
|
|
|
|
rich_colors: Var[bool] = field(
|
|
default=LiteralVar.create(True), doc="whether to show rich colors"
|
|
)
|
|
|
|
expand: Var[bool] = field(
|
|
default=LiteralVar.create(True), doc="whether to expand the toast"
|
|
)
|
|
|
|
visible_toasts: Var[int] = field(
|
|
doc="the number of toasts that are currently visible"
|
|
)
|
|
|
|
position: Var[LiteralPosition] = field(
|
|
default=LiteralVar.create("bottom-right"), doc="the position of the toast"
|
|
)
|
|
|
|
close_button: Var[bool] = field(
|
|
default=LiteralVar.create(False), doc="whether to show the close button"
|
|
)
|
|
|
|
offset: Var[str] = field(doc="offset of the toast")
|
|
|
|
dir: Var[str] = field(doc="directionality of the toast (default: ltr)")
|
|
|
|
hotkey: Var[str] = field(
|
|
doc="Keyboard shortcut that will move focus to the toaster area."
|
|
)
|
|
|
|
invert: Var[bool] = field(doc="Dark toasts in light mode and vice versa.")
|
|
|
|
toast_options: Var[ToastProps] = field(
|
|
doc="These will act as default options for all toasts. See toast() for all available options."
|
|
)
|
|
|
|
gap: Var[int] = field(doc="Gap between toasts when expanded")
|
|
|
|
loading_icon: Var[Icon] = field(doc="Changes the default loading icon")
|
|
|
|
pause_when_page_is_hidden: Var[bool] = field(
|
|
doc="Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked."
|
|
)
|
|
|
|
def add_hooks(self) -> list[Var | str]:
|
|
"""Add hooks for the toaster component.
|
|
|
|
Returns:
|
|
The hooks for the toaster component.
|
|
"""
|
|
if self.library is None:
|
|
return []
|
|
hook = Var(
|
|
_js_expr=f"{toast_ref} = toast",
|
|
_var_data=VarData(
|
|
imports={
|
|
"$/utils/state": [ImportVar(tag="refs")],
|
|
self.library: [ImportVar(tag="toast", install=False)],
|
|
}
|
|
),
|
|
)
|
|
return [hook]
|
|
|
|
@staticmethod
|
|
def send_toast(
|
|
message: str | Var[str] = "",
|
|
level: str | None = None,
|
|
fallback_to_alert: bool = False,
|
|
**props,
|
|
) -> EventSpec:
|
|
"""Send a toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
level: The level of the toast.
|
|
fallback_to_alert: Whether to fallback to an alert if the toaster is not created.
|
|
**props: The options for the toast.
|
|
|
|
Returns:
|
|
The toast event.
|
|
|
|
Raises:
|
|
ValueError: If the Toaster component is not created.
|
|
"""
|
|
toast_command = (
|
|
ObjectVar.__getattr__(toast_ref.to(dict), level) if level else toast_ref
|
|
).to(FunctionVar)
|
|
|
|
if isinstance(message, Var):
|
|
props.setdefault("title", message)
|
|
message = ""
|
|
elif message == "" and "title" not in props and "description" not in props:
|
|
msg = "Toast message or title or description must be provided."
|
|
raise ValueError(msg)
|
|
|
|
if props:
|
|
args = LiteralVar.create(ToastProps(component_name="rx.toast", **props)) # pyright: ignore [reportCallIssue]
|
|
toast = toast_command.call(message, args)
|
|
else:
|
|
toast = toast_command.call(message)
|
|
|
|
if fallback_to_alert:
|
|
toast = ternary_operation(
|
|
toast_ref.bool(),
|
|
toast,
|
|
FunctionVar("window.alert").call(
|
|
Var
|
|
.create(
|
|
message
|
|
if isinstance(message, str) and message
|
|
else props.get("title", props.get("description", ""))
|
|
)
|
|
.to(str)
|
|
.replace("<br/>", "\n")
|
|
),
|
|
)
|
|
|
|
return run_script(toast)
|
|
|
|
@staticmethod
|
|
def toast_info(message: str | Var[str] = "", **kwargs: Any) -> EventSpec:
|
|
"""Display an info toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
**kwargs: Additional toast props.
|
|
|
|
Returns:
|
|
The toast event.
|
|
"""
|
|
return Toaster.send_toast(message, level="info", **kwargs)
|
|
|
|
@staticmethod
|
|
def toast_warning(message: str | Var[str] = "", **kwargs: Any) -> EventSpec:
|
|
"""Display a warning toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
**kwargs: Additional toast props.
|
|
|
|
Returns:
|
|
The toast event.
|
|
"""
|
|
return Toaster.send_toast(message, level="warning", **kwargs)
|
|
|
|
@staticmethod
|
|
def toast_error(message: str | Var[str] = "", **kwargs: Any) -> EventSpec:
|
|
"""Display an error toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
**kwargs: Additional toast props.
|
|
|
|
Returns:
|
|
The toast event.
|
|
"""
|
|
return Toaster.send_toast(message, level="error", **kwargs)
|
|
|
|
@staticmethod
|
|
def toast_success(message: str | Var[str] = "", **kwargs: Any) -> EventSpec:
|
|
"""Display a success toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
**kwargs: Additional toast props.
|
|
|
|
Returns:
|
|
The toast event.
|
|
"""
|
|
return Toaster.send_toast(message, level="success", **kwargs)
|
|
|
|
@staticmethod
|
|
def toast_loading(message: str | Var[str] = "", **kwargs: Any) -> EventSpec:
|
|
"""Display a loading toast message.
|
|
|
|
Args:
|
|
message: The message to display.
|
|
**kwargs: Additional toast props.
|
|
|
|
Returns:
|
|
The toast event.
|
|
"""
|
|
return Toaster.send_toast(message, level="loading", **kwargs)
|
|
|
|
@staticmethod
|
|
def toast_dismiss(id: Var[str] | str | None = None) -> EventSpec:
|
|
"""Dismiss a toast.
|
|
|
|
Args:
|
|
id: The id of the toast to dismiss.
|
|
|
|
Returns:
|
|
The toast dismiss event.
|
|
"""
|
|
dismiss_var_data = None
|
|
|
|
if isinstance(id, Var):
|
|
dismiss = f"{toast_ref}.dismiss({id!s})"
|
|
dismiss_var_data = id._get_all_var_data()
|
|
elif isinstance(id, str):
|
|
dismiss = f"{toast_ref}.dismiss('{id}')"
|
|
else:
|
|
dismiss = f"{toast_ref}.dismiss()"
|
|
dismiss_action = Var(
|
|
_js_expr=dismiss, _var_data=VarData.merge(dismiss_var_data)
|
|
)
|
|
return run_script(dismiss_action)
|
|
|
|
@classmethod
|
|
def create(cls, *children: Any, **props: Any) -> Component:
|
|
"""Create a toaster component.
|
|
|
|
Args:
|
|
*children: The children of the toaster.
|
|
**props: The properties of the toaster.
|
|
|
|
Returns:
|
|
The toaster component.
|
|
"""
|
|
return super().create(*children, **props)
|
|
|
|
|
|
# TODO: figure out why loading toast stay open forever when using level="loading" in toast()
|
|
|
|
|
|
class ToastNamespace(ComponentNamespace):
|
|
"""Namespace for toast components."""
|
|
|
|
provider = staticmethod(Toaster.create)
|
|
options = staticmethod(ToastProps)
|
|
info = staticmethod(Toaster.toast_info)
|
|
warning = staticmethod(Toaster.toast_warning)
|
|
error = staticmethod(Toaster.toast_error)
|
|
success = staticmethod(Toaster.toast_success)
|
|
loading = staticmethod(Toaster.toast_loading)
|
|
dismiss = staticmethod(Toaster.toast_dismiss)
|
|
__call__ = staticmethod(Toaster.send_toast)
|
|
|
|
|
|
toast = ToastNamespace()
|