yew-toasts 0.1.0

Simple and easy toast notifications for Yew
Documentation
#![warn(
    clippy::all,
    clippy::missing_errors_doc,
    clippy::style,
    clippy::unseparated_literal_suffix,
    clippy::pedantic,
    clippy::nursery
)]

use web_sys::wasm_bindgen::JsCast;
use yew::prelude::*;

#[derive(Clone, PartialEq, Copy, Default, Eq)]
pub enum Gravity {
    #[default]
    Top,
    Bottom,
}
impl AsRef<str> for Gravity {
    fn as_ref(&self) -> &str {
        match self {
            Self::Top => "top",
            Self::Bottom => "bottom",
        }
    }
}

#[derive(Clone, PartialEq, Copy, Default, Eq)]
pub enum Position {
    Left,
    #[default]
    Right,
    Center,
}

impl AsRef<str> for Position {
    fn as_ref(&self) -> &str {
        match self {
            Self::Left => "left",
            Self::Right => "right",
            Self::Center => "center",
        }
    }
}

#[derive(Clone, PartialEq)]
pub struct ToastData {
    pub id: String,
    pub message: String,
    pub duration: u32,
    pub close: bool,
    pub gravity: Gravity,
    pub position: Position,
    pub element: Option<Html>,
}

impl Default for ToastData {
    fn default() -> Self {
        Self {
            id: format!("yew-toast-{}", web_sys::js_sys::Math::random()),
            message: String::new(),
            duration: 3000,
            close: true,
            gravity: Gravity::Top,
            position: Position::Right,
            element: None,
        }
    }
}

#[derive(Clone, PartialEq, Eq, Properties)]
pub struct ToastCornerContainerProps {
    pub gravity: Gravity,
    pub position: Position,
}

#[function_component(ToastCornerContainer)]
pub fn toast_corner_container(props: &ToastCornerContainerProps) -> Html {
    const fn container_style(gravity: Gravity, position: Position) -> &'static str {
        match (gravity, position) {
            (Gravity::Top, Position::Left) => {
                "position: fixed; top: 1rem; left: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
            }
            (Gravity::Top, Position::Right) => {
                "position: fixed; top: 1rem; right: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
            }
            (Gravity::Top, Position::Center) => {
                "position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;"
            }
            (Gravity::Bottom, Position::Left) => {
                "position: fixed; bottom: 1rem; left: 1rem; display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
            }
            (Gravity::Bottom, Position::Right) => {
                "position: fixed; bottom: 1rem; right: 1rem; display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
            }
            (Gravity::Bottom, Position::Center) => {
                "position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column-reverse; gap: 0.5rem; z-index: 9999;"
            }
        }
    }
    let style = container_style(props.gravity, props.position);
    html! {
        <div style={style} id={format!("toast-container-{}-{}", props.gravity.as_ref(), props.position.as_ref())}></div>
    }
}

#[function_component(ToastProvider)]
pub fn toast_provider(props: &yew::html::ChildrenProps) -> Html {
    // helper fn to get style string for the container
    html! {
        <>
            <style>
            {"
            @keyframes slideRight {
              0%   { opacity: 0; transform: translateX(100%); }
              10%  { opacity: 1; transform: translateX(0); }   /* finished entering */
              90%  { opacity: 1; transform: translateX(0); }   /* hold */
              100% { opacity: 0; transform: translateX(100%); }/* leave */
            }
            
            @keyframes slideLeft {
              0%   { opacity: 0; transform: translateX(-100%); }
              10%  { opacity: 1; transform: translateX(0); }
              90%  { opacity: 1; transform: translateX(0); }
              100% { opacity: 0; transform: translateX(-100%); }
            }
            
            @keyframes slideTop {
              0%   { opacity: 0; transform: translateY(-100%); }
              10%  { opacity: 1; transform: translateY(0); }
              90%  { opacity: 1; transform: translateY(0); }
              100% { opacity: 0; transform: translateY(-100%); }
            }
            
            @keyframes slideBottom {
              0%   { opacity: 0; transform: translateY(100%); }
              10%  { opacity: 1; transform: translateY(0); }
              90%  { opacity: 1; transform: translateY(0); }
              100% { opacity: 0; transform: translateY(100%); }
            }
            "}
            </style>
            {props.children.clone()}
            // Render one container per gravity+position
            <ToastCornerContainer gravity={Gravity::Top} position={Position::Left}/>
            <ToastCornerContainer gravity={Gravity::Top} position={Position::Right}/>
            <ToastCornerContainer gravity={Gravity::Top} position={Position::Center}/>
            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Left}/>
            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Right}/>
            <ToastCornerContainer gravity={Gravity::Bottom} position={Position::Center}/>
        </>
    }
}

#[derive(Clone, PartialEq, Properties)]
struct ToastHolderProps {
    id: String,
    duration: u32,
    children: Html,
    gravity: Gravity,
    position: Position,
}

#[function_component(ToastHolder)]
fn toast_holder(props: &ToastHolderProps) -> Html {
    // let animation_duration = format!("{}ms", props.duration);
    let animation = match (props.gravity, props.position) {
        (_, Position::Left) => "slideLeft",
        (_, Position::Right) => "slideRight",
        (Gravity::Top, _) => "slideTop",
        (Gravity::Bottom, _) => "slideBottom",
    };
    html! {
       <div id={props.id.clone()} style={format!("animation: {} {}ms ease-in-out;", animation, props.duration)}>
           {props.children.clone()}
       </div>
    }
}
#[must_use]
pub fn show_toast() -> Callback<ToastData> {
    fn show_toast(toast: &ToastData) -> Result<(), web_sys::wasm_bindgen::JsValue> {
        let Some(doc) = web_sys::window().and_then(|win| win.document()) else {
            return Err(web_sys::wasm_bindgen::JsValue::from_str(
                "document not found",
            ));
        };

        // pick correct container
        let container_id = format!(
            "toast-container-{}-{}",
            toast.gravity.as_ref(),
            toast.position.as_ref()
        );
        let Some(container) = doc.get_element_by_id(&container_id) else {
            return Err(web_sys::wasm_bindgen::JsValue::from_str(
                "container not found",
            ));
        };

        // create toast element
        let div = doc
            .create_element("div")?
            .dyn_into::<web_sys::HtmlElement>()?;

        div.set_id(&toast.id);
        if let Some(element) = toast.element.as_ref() {
            yew::Renderer::<ToastHolder>::with_root_and_props(
                div.clone().unchecked_into(),
                ToastHolderProps {
                    id: toast.id.clone(),
                    children: element.clone(),
                    duration: toast.duration,
                    gravity: toast.gravity,
                    position: toast.position,
                },
            )
            .render();
        }
        let div_clone = div.clone();
        if toast.close {
            div.set_onclick(Some(
                web_sys::wasm_bindgen::closure::Closure::once_into_js(
                    move |_: web_sys::HtmlElement| {
                        div_clone.remove();
                    },
                )
                .unchecked_ref(),
            ));
        }
        container.append_child(&div)?;

        // schedule removal
        let div_clone = div.clone();

        div.set_onanimationend(Some(
            web_sys::wasm_bindgen::closure::Closure::once_into_js(
                move |_: web_sys::HtmlElement| {
                    div_clone.remove();
                },
            )
            .unchecked_ref(),
        ));
        Ok(())
    }
    Callback::from(move |new_toast: ToastData| {
        if let Err(e) = show_toast(&new_toast) {
            web_sys::console::error_1(&e);
        }
    })
}

#[cfg(test)]
mod tests {}