1#![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#[derive(Clone, PartialEq, Copy, Default, Eq)]
16pub enum Gravity {
17 #[default]
18 Top,
20 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#[derive(Clone, PartialEq, Copy, Default, Eq)]
34pub enum Position {
35 Left,
37 #[default]
39 Right,
40 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#[derive(Clone, PartialEq)]
67pub struct ToastData {
68 pub element: Html,
70 pub duration: u32,
72 pub close: bool,
74 pub gravity: Gravity,
76 pub position: Position,
78 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#[derive(Clone, PartialEq, Eq, Properties)]
97pub struct ToastCornerContainerProps {
98 pub gravity: Gravity,
100 pub position: Position,
102}
103
104#[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#[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 = 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}
205pub 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#[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