yew_toasts/
lib.rs

1//! A simple and easy toast notifications for Yew.
2#![warn(
3    missing_docs,
4    clippy::all,
5    clippy::missing_errors_doc,
6    clippy::style,
7    clippy::unseparated_literal_suffix,
8    clippy::pedantic,
9    clippy::nursery
10)]
11use web_sys::wasm_bindgen::JsCast;
12use yew::prelude::*;
13
14/// The vertical position of the toast
15#[derive(Clone, PartialEq, Copy, Default, Eq)]
16pub enum Gravity {
17    #[default]
18    /// The toast will be placed at the top of the screen
19    Top,
20    /// The toast will be placed at the bottom of the screen
21    Bottom,
22}
23impl AsRef<str> for Gravity {
24    fn as_ref(&self) -> &str {
25        match self {
26            Self::Top => "top",
27            Self::Bottom => "bottom",
28        }
29    }
30}
31
32/// The horizontal position of the toast
33#[derive(Clone, PartialEq, Copy, Default, Eq)]
34pub enum Position {
35    /// The toast will be placed on the left side of the screen
36    Left,
37    /// The toast will be placed on the right side of the screen
38    #[default]
39    Right,
40    /// The toast will be placed in the center of the screen
41    Center,
42}
43
44impl AsRef<str> for Position {
45    fn as_ref(&self) -> &str {
46        match self {
47            Self::Left => "left",
48            Self::Right => "right",
49            Self::Center => "center",
50        }
51    }
52}
53
54/// The data needed to render a toast
55///
56/// # Properties
57///
58/// - `element`: The HTML element to render inside the toast
59/// - `duration`: The duration of the toast in milliseconds
60/// - `close`: Whether the toast should close when clicked
61/// - `gravity`: The vertical position of the toast
62/// - `position`: The horizontal position of the toast
63/// - `onclick`: The callback to be called when the toast is clicked
64///
65/// Setting `close` to `true` will override the `onclick` callback.
66#[derive(Clone, PartialEq)]
67pub struct ToastData {
68    /// The HTML element to render inside the toast
69    pub element: Html,
70    /// The duration of the toast in milliseconds
71    pub duration: u32,
72    /// Whether the toast should close when clicked
73    pub close: bool,
74    /// The vertical position of the toast
75    pub gravity: Gravity,
76    /// The horizontal position of the toast
77    pub position: Position,
78    /// The callback to be called when the toast is clicked
79    pub onclick: Callback<()>,
80}
81impl Default for ToastData {
82    fn default() -> Self {
83        Self {
84            duration: 3000,
85            close: false,
86            gravity: Gravity::Top,
87            position: Position::Right,
88            element: html!(<div></div>),
89            onclick: Callback::noop(),
90        }
91    }
92}
93
94
95/// Props for toast containers, these are used to position the toasts.
96#[derive(Clone, PartialEq, Eq, Properties)]
97pub struct ToastCornerContainerProps {
98    /// The vertical position of the toast
99    pub gravity: Gravity,
100    /// The horizontal position of the toast
101    pub position: Position,
102}
103
104/// A container for toasts, these holde the toasts and position them.
105#[function_component(ToastCornerContainer)]
106pub fn toast_corner_container(props: &ToastCornerContainerProps) -> Html {
107    const fn container_style(gravity: Gravity, position: Position) -> &'static str {
108        match (gravity, position) {
109            (Gravity::Top, Position::Left) => {
110                "position: fixed; top: 1rem; left: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
111            }
112            (Gravity::Top, Position::Right) => {
113                "position: fixed; top: 1rem; right: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
114            }
115            (Gravity::Top, Position::Center) => {
116                "position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
117            }
118            (Gravity::Bottom, Position::Left) => {
119                "position: fixed; bottom: 1rem; left: 1rem; display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
120            }
121            (Gravity::Bottom, Position::Right) => {
122                "position: fixed; bottom: 1rem; right: 1rem; display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
123            }
124            (Gravity::Bottom, Position::Center) => {
125                "position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
126            }
127        }
128    }
129    let style = container_style(props.gravity, props.position);
130    html! {
131        <div {style} id={format!("toast-container-{}-{}", props.gravity.as_ref(), props.position.as_ref())} />
132    }
133}
134
135/// A provider for toasts, renders the toast containers and 
136/// add a style sheet for the keyframe animations.
137#[function_component(ToastProvider)]
138pub fn toast_provider() -> Html {
139    html! {
140        <>
141            <style>
142            {"
143            @keyframes slideRight {
144              0%   { opacity: 0; transform: translateX(100%); }
145              10%  { opacity: 1; transform: translateX(0); }   /* finished entering */
146              90%  { opacity: 1; transform: translateX(0); }   /* hold */
147              100% { opacity: 0; transform: translateX(100%); }/* leave */
148            }
149            
150            @keyframes slideLeft {
151              0%   { opacity: 0; transform: translateX(-100%); }
152              10%  { opacity: 1; transform: translateX(0); }
153              90%  { opacity: 1; transform: translateX(0); }
154              100% { opacity: 0; transform: translateX(-100%); }
155            }
156            
157            @keyframes slideTop {
158              0%   { opacity: 0; transform: translateY(-100%); }
159              10%  { opacity: 1; transform: translateY(0); }
160              90%  { opacity: 1; transform: translateY(0); }
161              100% { opacity: 0; transform: translateY(-100%); }
162            }
163            
164            @keyframes slideBottom {
165              0%   { opacity: 0; transform: translateY(100%); }
166              10%  { opacity: 1; transform: translateY(0); }
167              90%  { opacity: 1; transform: translateY(0); }
168              100% { opacity: 0; transform: translateY(100%); }
169            }
170            "}
171            </style>
172            <ToastCornerContainer gravity={Gravity::Top} position={Position::Left}/>
173            <ToastCornerContainer gravity={Gravity::Top} position={Position::Right}/>
174            <ToastCornerContainer gravity={Gravity::Top} position={Position::Center}/>
175            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Left}/>
176            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Right}/>
177            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Center}/>
178        </>
179    }
180}
181
182#[derive(Clone, PartialEq, Properties)]
183struct ToastHolderProps {
184    duration: u32,
185    children: Html,
186    gravity: Gravity,
187    position: Position,
188}
189
190#[function_component(ToastHolder)]
191fn toast_holder(props: &ToastHolderProps) -> Html {
192    // let animation_duration = format!("{}ms", props.duration);
193    let animation = match (props.gravity, props.position) {
194        (_, Position::Left) => "slideLeft",
195        (_, Position::Right) => "slideRight",
196        (Gravity::Top, Position::Center) => "slideTop",
197        (Gravity::Bottom, Position::Center) => "slideBottom",
198    };
199    html! {
200       <div style={format!("animation: {} {}ms ease-in-out;", animation, props.duration)}>
201           {props.children.clone()}
202       </div>
203    }
204}
205/// Builds and renders a new `Toast` according to
206/// the parameters passed.
207///
208/// # Errors
209///
210/// Will error if it cannot find the appropiate container already rendered.
211pub fn show_toast(toast: &ToastData) -> Result<(), web_sys::wasm_bindgen::JsValue> {
212    let Some(doc) = web_sys::window().and_then(|win| win.document()) else {
213        return Err(web_sys::wasm_bindgen::JsValue::from_str(
214            "document not found",
215        ));
216    };
217
218    let container_id = format!(
219        "toast-container-{}-{}",
220        toast.gravity.as_ref(),
221        toast.position.as_ref()
222    );
223    let Some(container) = doc.get_element_by_id(&container_id) else {
224        return Err(web_sys::wasm_bindgen::JsValue::from_str(
225            "container not found",
226        ));
227    };
228
229    let div = doc
230        .create_element("div")?
231        .dyn_into::<web_sys::HtmlElement>()?;
232    let div_clone = div.clone();
233    if toast.close {
234        div.set_onclick(Some(
235            web_sys::wasm_bindgen::closure::Closure::once_into_js(
236                move |_: web_sys::HtmlElement| {
237                    div_clone.remove();
238                },
239            )
240            .unchecked_ref(),
241        ));
242    } else {
243        let cb = toast.onclick.clone();
244        let closure = web_sys::wasm_bindgen::closure::Closure::wrap(Box::new(move || {
245            cb.emit(());
246        }) as Box<dyn Fn()>);
247        let function = closure.as_ref().clone().unchecked_into();
248        closure.forget();
249        div.set_onclick(Some(&function));
250    }
251
252    let div_clone = div.clone();
253    div.set_onanimationend(Some(
254        web_sys::wasm_bindgen::closure::Closure::once_into_js(move |_: web_sys::HtmlElement| {
255            div_clone.remove();
256        })
257        .unchecked_ref(),
258    ));
259
260    container.append_child(&div)?;
261    yew::Renderer::<ToastHolder>::with_root_and_props(
262        div.unchecked_into(),
263        ToastHolderProps {
264            children: toast.element.clone(),
265            duration: toast.duration,
266            gravity: toast.gravity,
267            position: toast.position,
268        },
269    )
270    .render();
271    Ok(())
272}
273/// Reusable Yew `Callback` that can be reformed
274/// and reused betwen different elements.
275#[must_use]
276pub fn show_toast_cb() -> Callback<ToastData> {
277    Callback::from(move |toast: ToastData| {
278        if let Err(e) = show_toast(&toast) {
279            web_sys::console::error_1(&e);
280        }
281    })
282}
283