Refactor toast notifications and modal dialog implementation

- Updated SCSS for toast notifications to support multiple toast types (success, info, warning, error) and improved layout.
- Added new SVG icons for error, info, success, and warning notifications.
- Created separate HTML templates for toast notifications and modal dialogs.
- Enhanced the dev panel with buttons to test different toast notifications and modal dialogs.
This commit is contained in:
Greg Burri 2025-04-27 12:49:39 +02:00
parent 7b9df97a32
commit cf9c6b2a3f
15 changed files with 399 additions and 70 deletions

View file

@ -1,21 +1,39 @@
@use 'constants' as consts;
#toast {
visibility: hidden;
width: 300px;
margin-left: -125px;
border: 0.1em solid consts.$color-3;
border-radius: 0.5em;
background-color: consts.$color-2;
text-align: center;
padding: consts.$margin;
#toasts {
position: fixed;
z-index: 1;
left: 50%;
top: 30px;
box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3);
top: 15px;
transform: translate(-50%, 0%);
display: flex;
flex-direction: column;
.toast {
// visibility: hidden;
display: none;
width: fit-content;
align-self: center;
border: 0.1em solid consts.$color-3;
border-radius: 0.5em;
background-color: consts.$color-2;
text-align: center;
padding: calc(2 * consts.$margin) calc(4 * consts.$margin);
box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3);
margin: consts.$margin;
.content {
display: inline-block;
}
img {
vertical-align: text-bottom;
}
}
}
// #toast.show {

54
backend/static/error.svg Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="40.905399mm"
height="40.905399mm"
viewBox="0 0 40.905399 40.905399"
version="1.1"
id="svg1"
sodipodi:docname="error.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.4702524"
inkscape:cx="-96.582055"
inkscape:cy="13.263029"
inkscape:window-width="2560"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="828"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.178404,-120.49168)">
<circle
style="fill:#ff1e1e;fill-opacity:1;stroke:#a00000;stroke-width:7;stroke-linecap:butt;stroke-linejoin:bevel;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="path2"
cx="100.6311"
cy="140.94438"
r="16.9527" />
<path
d="m 107.32373,131.05185 -6.61942,6.62988 q -0.0732,0.0628 -0.13595,0 l -6.629879,-6.62988 q -0.658805,-0.6588 -1.599955,-0.6588 -0.930694,0 -1.589499,0.6588 -0.669262,0.66926 -0.669262,1.61042 0,0.93069 0.669262,1.58949 l 6.619425,6.62989 q 0.0732,0.0627 0,0.12548 l -6.619425,6.62988 q -0.669262,0.65881 -0.669262,1.5895 0,0.94115 0.669262,1.61042 0.658805,0.6588 1.589499,0.6588 0.94115,0 1.599955,-0.6588 l 6.629879,-6.62989 q 0.0628,-0.0627 0.13595,0 l 6.61942,6.62989 q 0.65881,0.6588 1.59996,0.6588 0.94115,0 1.59995,-0.6588 0.65881,-0.66927 0.65881,-1.61042 0,-0.93069 -0.65881,-1.5895 l -6.62988,-6.62988 q -0.0627,-0.0627 0,-0.12548 l 6.62988,-6.62989 q 0.65881,-0.6588 0.65881,-1.58949 0,-0.94116 -0.65881,-1.61042 -0.6588,-0.6588 -1.59995,-0.6588 -0.94115,0 -1.59996,0.6588 z"
id="text1"
style="font-size:21.4164px;font-family:'Arial Rounded MT Bold';-inkscape-font-specification:'Arial Rounded MT Bold, ';fill:#ffffff;stroke-width:8.36709;stroke-linejoin:round;paint-order:stroke fill markers"
aria-label="❌" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

54
backend/static/info.svg Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="40.905399mm"
height="40.905399mm"
viewBox="0 0 40.905399 40.905399"
version="1.1"
id="svg1"
sodipodi:docname="info.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.4702524"
inkscape:cx="-31.287145"
inkscape:cy="44.550174"
inkscape:window-width="2560"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="828"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.178404,-120.49168)">
<circle
style="fill:#1e22ff;fill-opacity:1;stroke:#0003a0;stroke-width:7;stroke-linecap:butt;stroke-linejoin:bevel;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="path2"
cx="100.6311"
cy="140.94438"
r="16.9527" />
<path
d="m 103.30394,136.96938 v 15.44961 q 0,1.60566 -0.76367,2.42808 -0.76367,0.82241 -1.93854,0.82241 -1.174876,0 -1.918964,-0.842 -0.724506,-0.84199 -0.724506,-2.40849 v -15.29296 q 0,-1.58608 0.724506,-2.38891 0.744088,-0.80283 1.918964,-0.80283 1.17487,0 1.93854,0.80283 0.76367,0.80283 0.76367,2.23226 z m -2.64347,-5.52191 q -1.116129,0 -1.91896,-0.68534 -0.78325,-0.68535 -0.78325,-1.93855 0,-1.13571 0.802831,-1.86022 0.822413,-0.74408 1.899379,-0.74408 1.03781,0 1.84064,0.66576 0.80283,0.66576 0.80283,1.93854 0,1.23362 -0.78325,1.93855 -0.78325,0.68534 -1.86022,0.68534 z"
id="text1"
style="font-size:40.1024px;font-family:'Arial Rounded MT Bold';-inkscape-font-specification:'Arial Rounded MT Bold, ';fill:#ffffff;stroke-width:19.8934;stroke-linejoin:round;paint-order:stroke fill markers"
aria-label="i" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="40.905399mm"
height="40.905399mm"
viewBox="0 0 40.905399 40.905399"
version="1.1"
id="svg1"
sodipodi:docname="success.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.73512621"
inkscape:cx="-352.32046"
inkscape:cy="-74.13693"
inkscape:window-width="2560"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="828"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.178404,-120.49168)">
<circle
style="fill:#03b500;fill-opacity:1;stroke:#027500;stroke-width:7;stroke-linecap:butt;stroke-linejoin:bevel;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="path2"
cx="100.6311"
cy="140.94438"
r="16.9527" />
<path
d="m 112.46328,132.63911 q -0.58288,-0.62262 -1.37771,-0.84782 -0.78159,-0.2252 -1.58967,-0.0397 -0.79483,0.17221 -1.41745,0.75509 l -11.962235,11.27338 -3.85494,-3.96092 q -0.596125,-0.60937 -1.390958,-0.82132 -0.781586,-0.21196 -1.576419,-0.0132 -0.794833,0.18546 -1.417452,0.78158 -0.609372,0.59613 -0.821328,1.39096 -0.211955,0.79484 -0.02649,1.58967 0.198708,0.79483 0.794833,1.4042 l 5.974495,6.14671 q 0.887563,0.91406 2.159296,0.94055 1.271733,0.0265 2.199038,-0.84782 l 14.174524,-13.36644 q 0.62262,-0.58288 0.84782,-1.36446 0.2252,-0.79484 0.0397,-1.58967 -0.17221,-0.80808 -0.75509,-1.4307 z"
id="text1"
style="font-size:27.1303px;font-family:'Arial Rounded MT Bold';-inkscape-font-specification:'Arial Rounded MT Bold, ';fill:#ffffff;stroke-width:13.4584;stroke-linejoin:round;paint-order:stroke fill markers"
aria-label="✔️" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="40.905399mm"
height="40.905399mm"
viewBox="0 0 40.905399 40.905399"
version="1.1"
id="svg1"
sodipodi:docname="warning.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.0396254"
inkscape:cx="-80.79833"
inkscape:cy="65.889115"
inkscape:window-width="2560"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="828"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.178404,-120.49168)">
<path
style="fill:#ffff1a;fill-opacity:1;stroke:#616100;stroke-width:5.513;stroke-linecap:butt;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 84.523721,157.05141 16.107569,-32.21437 16.10679,32.21437 z"
id="path1"
sodipodi:nodetypes="cccc" />
<path
style="font-size:37.4875px;font-family:'Arial Rounded MT Bold';-inkscape-font-specification:'Arial Rounded MT Bold, ';fill:#000000;fill-opacity:1;stroke-width:2.49752"
d="m 98.737968,144.66325 -0.550671,-8.24286 q -0.154876,-2.40918 -0.154876,-3.4589 0,-1.4283 0.739965,-2.2199 0.757173,-0.80879 1.978984,-0.80879 1.47992,0 1.97897,1.03251 0.49904,1.01529 0.49904,2.94265 0,1.13576 -0.12046,2.30593 l -0.73996,8.48378 q -0.12047,1.51435 -0.51626,2.32314 -0.39579,0.8088 -1.30784,0.8088 -0.929259,0 -1.290638,-0.77438 -0.361377,-0.79159 -0.516254,-2.39198 z m 1.910142,11.32318 q -1.049718,0 -1.841307,-0.67113 -0.774382,-0.68834 -0.774382,-1.91015 0,-1.06692 0.739965,-1.80688 0.757173,-0.75717 1.841304,-0.75717 1.08413,0 1.84131,0.75717 0.77438,0.73996 0.77438,1.80688 0,1.2046 -0.77438,1.89294 -0.77438,0.68834 -1.80689,0.68834 z"
id="text1"
aria-label="!" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -23,15 +23,8 @@
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}}));
</script>
<div id="toasts">
<div id="toast"></div>
</div>
<dialog id="modal-dialog">
<div class="content"></div>
<input type="button" class="ok" value="OK">
<input type="button" class="cancel" value="Cancel">
</dialog>
{% include "toast.html" %}
{% include "modal_dialog.html" %}
{% block body_container %}{% endblock %}

View file

@ -3,14 +3,31 @@
{% block content %}
<div class="content" id="dev-panel">
<input type="button" class="button" id="test-toast" value="Test toast">
<input type="button" class="button" id="test-modal-dialog" value="Test modal">
<div>
<input type="button" class="button" id="test-toast" value="Test toast">
<input type="button" class="button" id="test-toast-success" value="Test toast success">
<input type="button" class="button" id="test-toast-info" value="Test toast info">
<input type="button" class="button" id="test-toast-warning" value="Test toast warning">
<input type="button" class="button" id="test-toast-error" value="Test toast error">
<input type="button" class="button" id="test-toast-success-content" value="Test toast content">
<input type="button" class="button" id="test-toast-success-content-initializer" value="Test toast content initializer">
</div>
<div>
<input type="button" class="button" id="test-modal-dialog" value="Test modal">
</div>
</div>
<div id="hidden-templates">
<div class="modal-test-message">
This is a message.
</div>
<div class="toast-test-content">
<span>Item 1</span>
<span>Item 2</span>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,6 @@
{# Needed by the frontend modal_dialog module. #}
<dialog id="modal-dialog">
<div class="content"></div>
<input type="button" class="ok" value="OK">
<input type="button" class="cancel" value="Cancel">
</dialog>

View file

@ -0,0 +1,8 @@
{# Needed by the frontend toast module. #}
<div id="toasts">
<div class="toast">
<img class="icon" width="24" height="24">
<div class="content"></div>
<span class="close button"></span>
</div>
</div>

View file

@ -44,6 +44,7 @@ web-sys = { version = "0.3", features = [
"HtmlDivElement",
"HtmlLabelElement",
"HtmlInputElement",
"HtmlImageElement",
"HtmlTextAreaElement",
"HtmlSelectElement",
"HtmlDialogElement",

View file

@ -1,7 +1,7 @@
use gloo::{console::log, events::EventListener};
use gloo::{console::log, events::EventListener, utils::document};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{Element, HtmlInputElement};
use web_sys::{Element, HtmlElement, HtmlInputElement};
use crate::{
calendar, modal_dialog,
@ -13,10 +13,56 @@ use crate::{
pub fn setup_page() {
EventListener::new(&by_id("test-toast"), "click", move |_event| {
toast::show_message(Level::Info, "This is a message");
toast::show_message("This is a message");
})
.forget();
EventListener::new(&by_id("test-toast-success"), "click", move |_event| {
toast::show_message_level(Level::Success, "This is a success message");
})
.forget();
EventListener::new(&by_id("test-toast-error"), "click", move |_event| {
toast::show_message_level(Level::Error, "This is a error message");
})
.forget();
EventListener::new(&by_id("test-toast-info"), "click", move |_event| {
toast::show_message_level(Level::Info, "This is an info message");
})
.forget();
EventListener::new(&by_id("test-toast-warning"), "click", move |_event| {
toast::show_message_level(Level::Warning, "This is a warning message");
})
.forget();
EventListener::new(
&by_id("test-toast-success-content"),
"click",
move |_event| {
toast::show_element_level(Level::Success, "#hidden-templates .toast-test-content");
},
)
.forget();
EventListener::new(
&by_id("test-toast-success-content-initializer"),
"click",
move |_event| {
toast::show_element_level_and_initialize(
Level::Success,
"#hidden-templates .toast-test-content",
|element| {
let new_span = document().create_element("span").unwrap();
new_span.set_inner_html("Item 3");
element.append_child(&new_span).unwrap();
},
);
},
)
.forget();
EventListener::new(&by_id("test-modal-dialog"), "click", move |_event| {
spawn_local(async move {
modal_dialog::show("#hidden-templates").await;

View file

@ -76,7 +76,6 @@ pub fn setup_page(recipe_id: i64) {
let servings = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
servings.set_value_as_number(n as f64);
Some(n)
@ -108,7 +107,6 @@ pub fn setup_page(recipe_id: i64) {
let time = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
estimated_time.set_value_as_number(n as f64);
Some(n)
@ -708,7 +706,7 @@ async fn reload_recipes_list(current_recipe_id: i64) {
.await
{
Err(error) => {
toast::show_message(Level::Info, &format!("Internal server error: {}", error));
toast::show_message_level(Level::Error, &format!("Internal server error: {}", error));
}
Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap();

View file

@ -52,7 +52,7 @@ pub fn setup_page(recipe_id: i64, is_user_logged: bool, first_day_of_the_week: W
.shedule_recipe(recipe_id, date, servings, add_ingredients_to_shopping_list)
.await
{
toast::show_element_and_initialize(
toast::show_element_level_and_initialize(
match result {
ScheduleRecipeResult::Ok => Level::Success,
ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {

View file

@ -64,13 +64,13 @@ where
{
match request.send().await {
Err(error) => {
toast::show_message(Level::Info, &format!("Internal server error: {}", error));
toast::show_message_level(Level::Error, &format!("Internal server error: {}", error));
Err(Error::Gloo(error))
}
Ok(response) => {
if !response.ok() {
toast::show_message(
Level::Info,
toast::show_message_level(
Level::Error,
&format!("HTTP error: {}", response.status_text()),
);
Err(Error::Http(response.status_text()))

View file

@ -1,5 +1,5 @@
use gloo::{timers::callback::Timeout, utils::document};
use web_sys::{Element, HtmlElement};
use gloo::{events::EventListener, timers::callback::Timeout};
use web_sys::{Element, HtmlElement, HtmlImageElement};
use crate::utils::{SelectorExt, by_id, selector_and_clone};
@ -8,55 +8,82 @@ pub enum Level {
Error,
Info,
Warning,
Unknown,
}
/*
TODO:
- Stack multiple toast messages (see #toasts) by cloning #toast
- User can close message by clicking a button
- Implement level display with icons
*/
const TIME_ANIMATION: u32 = 500; // [ms].
const TIME_DISPLAYED: u32 = 5_000; // [ms].
pub fn show_message(level: Level, message: &str) {
let toast_element: HtmlElement = by_id("toast");
toast_element.set_inner_html(message);
toast_element.style().set_css_text(&format!(
"visibility: visible;
animation:
fadein {}ms,
fadeout {}ms {}ms;",
TIME_ANIMATION,
TIME_ANIMATION,
TIME_DISPLAYED - TIME_ANIMATION
));
Timeout::new(TIME_DISPLAYED, move || {
toast_element.style().set_css_text("");
})
.forget();
pub fn show_message(message: &str) {
show_message_level(Level::Unknown, message);
}
pub fn show_element(level: Level, selector: &str) {
show_element_and_initialize(level, selector, |_| {})
pub fn show_message_level(level: Level, message: &str) {
show_message_content(level, Content::Message(message))
}
pub fn show_element_and_initialize<T>(level: Level, selector: &str, initializer: T)
pub fn show_element_level(level: Level, selector: &str) {
show_element_level_and_initialize(level, selector, |_| {})
}
pub fn show_element_level_and_initialize<T>(level: Level, selector: &str, initializer: T)
where
T: Fn(Element),
{
let toast_element = document().get_element_by_id("toast").unwrap();
let content_element: Element = selector_and_clone(selector);
initializer(content_element.clone());
let element: Element = selector_and_clone(selector);
toast_element.set_inner_html("");
toast_element.append_child(&element).unwrap();
initializer(element.clone());
toast_element.set_class_name("show");
show_message_content(level, Content::Element(content_element))
}
enum Content<'a> {
Message(&'a str),
Element(Element),
}
fn show_message_content(level: Level, content: Content) {
let toast_element: HtmlElement = selector_and_clone("#toasts .toast");
let toast_icon: HtmlImageElement = toast_element.selector(".icon");
let toast_content: HtmlElement = toast_element.selector(".content");
match level {
Level::Success => toast_icon.set_src("/static/success.svg"),
Level::Error => toast_icon.set_src("/static/error.svg"),
Level::Info => toast_icon.set_src("/static/info.svg"),
Level::Warning => toast_icon.set_src("/static/warning.svg"),
Level::Unknown => toast_icon.remove(),
}
match content {
Content::Message(message) => toast_content.set_inner_html(message),
Content::Element(element) => {
let _ = toast_content.append_child(&element);
}
}
toast_element.style().set_css_text(&format!(
"display: block;
animation:
fadein {}ms,
fadeout {}ms {}ms;",
TIME_ANIMATION, TIME_ANIMATION, TIME_DISPLAYED
));
// FIXME: Here the two events will leak memory. How to fix that?
// Save them in a global vec variable and remove them manually?
let close_button: HtmlElement = toast_element.selector(".close");
let toast_element_cloned = toast_element.clone();
EventListener::once(&close_button, "click", move |_event| {
toast_element_cloned.remove();
})
.forget();
let toasts: HtmlElement = by_id("toasts");
toasts.append_child(&toast_element).unwrap();
Timeout::new(TIME_DISPLAYED, move || {
toast_element.set_class_name("");
toast_element.remove();
})
.forget();
}