Skip to content

Commit 90c5fdf

Browse files
authored
Merge pull request #519 from materialsproject/error-msg
add ErrorMessageAIO
2 parents 02e57a0 + 8f085f5 commit 90c5fdf

3 files changed

Lines changed: 370 additions & 15 deletions

File tree

crystal_toolkit/components/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
)
77
from crystal_toolkit.components.diffraction import XRayDiffractionComponent
88
from crystal_toolkit.components.diffraction_tem import TEMDiffractionComponent
9+
from crystal_toolkit.components.error_msg import ErrorMessageAIO
910
from crystal_toolkit.components.fermi_surface import FermiSurfaceComponent
1011
from crystal_toolkit.components.localenv import LocalEnvironmentPanel
1112
from crystal_toolkit.components.phase_diagram import (
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
"""
2+
Author: Sheng Pang
3+
Modifier: Min-Hsueh Chiu
4+
5+
Message Snake - A reusable Dash notification snackbar component.
6+
7+
Provides fixed-position toast notifications callable from any page.
8+
Supports fade-in/fade-out animations, auto-dismiss, and manual close.
9+
10+
Usage:
11+
from crystal_toolkit.components.error_msg import ErrorMessageAIO
12+
13+
# 1. Include in layout
14+
ErrorMessageAIO(
15+
"Invalid composition input!",
16+
aio_id=self.id("invalid-comp-alarm"),
17+
msg_type="error",
18+
),
19+
20+
# 2. Add to callback:
21+
Output(ErrorMessage.ids.visible(self.id("invalid-comp-alarm")), "data"),
22+
# Return True to display the message, and False to hide it.
23+
24+
Note: Do not need to register callbacks as using All-in-one pattern
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from dash import MATCH, Input, Output, callback, ctx, dcc, html
30+
31+
from crystal_toolkit.core.mpcomponent import MPComponent
32+
33+
# Bulma-inspired color scheme for notification types
34+
_TYPE_COLORS = {
35+
"info": {
36+
"background": "hsl(217, 71%, 53%)", # Bulma $info / $link (#3273dc)
37+
"color": "#ffffff",
38+
},
39+
"warning": {
40+
"background": "hsl(44, 100%, 47%)", # Bulma-inspired darker yellow/orange
41+
"color": "#ffffff",
42+
},
43+
"error": {
44+
"background": "hsl(348, 86%, 61%)", # Bulma $danger (#f14668)
45+
"color": "#ffffff",
46+
},
47+
"success": {
48+
"background": "hsl(153, 53%, 53%)", # Bulma $success (#48c78e)
49+
"color": "#ffffff",
50+
},
51+
}
52+
53+
# Position presets: each maps to CSS properties
54+
_POSITION_STYLES = {
55+
"top": {
56+
"top": "20px",
57+
"left": "50%",
58+
"transform": "translateX(-50%)",
59+
},
60+
"bottom": {
61+
"bottom": "20px",
62+
"left": "50%",
63+
"transform": "translateX(-50%)",
64+
},
65+
"center": {
66+
"top": "50%",
67+
"left": "50%",
68+
"transform": "translate(-50%, -50%)",
69+
},
70+
"top-right": {
71+
"top": "20px",
72+
"right": "20px",
73+
},
74+
"top-left": {
75+
"top": "20px",
76+
"left": "20px",
77+
},
78+
"bottom-right": {
79+
"bottom": "20px",
80+
"right": "20px",
81+
},
82+
"bottom-left": {
83+
"bottom": "20px",
84+
"left": "20px",
85+
},
86+
}
87+
88+
# Icons per message type (Font Awesome classes)
89+
_TYPE_ICONS = {
90+
"info": "fas fa-info-circle",
91+
"warning": "fas fa-exclamation-triangle",
92+
"error": "fas fa-times-circle",
93+
"success": "fas fa-check-circle",
94+
}
95+
96+
97+
class ErrorMessageAIO(html.Div, MPComponent):
98+
class ids:
99+
wrapper = lambda aio_id: {
100+
"component": "ErrorMessageAIO",
101+
"subcomponents": "wrapper",
102+
"aio_id": aio_id,
103+
}
104+
close_button = lambda aio_id: {
105+
"component": "ErrorMessageAIO",
106+
"subcomponents": "close_button",
107+
"aio_id": aio_id,
108+
}
109+
message = lambda aio_id: {
110+
"component": "ErrorMessageAIO",
111+
"subcomponents": "message",
112+
"aio_id": aio_id,
113+
}
114+
div = lambda aio_id: {
115+
"component": "ErrorMessageAIO",
116+
"subcomponents": "div",
117+
"aio_id": aio_id,
118+
}
119+
timer = lambda aio_id: {
120+
"component": "ErrorMessageAIO",
121+
"subcomponents": "timer",
122+
"aio_id": aio_id,
123+
}
124+
visible = lambda aio_id: {
125+
"component": "ErrorMessageAIO",
126+
"subcomponents": "visible",
127+
"aio_id": aio_id,
128+
}
129+
130+
ids = ids
131+
132+
# _callbacks_registered = False # no instance registry
133+
134+
def __init__(
135+
self,
136+
message,
137+
aio_id,
138+
msg_type,
139+
position="bottom-right",
140+
style=None,
141+
show_icon=False,
142+
min_width="280px",
143+
max_width="420px",
144+
z_index=9999,
145+
auto_dismiss_ms=50000,
146+
):
147+
"""Create a fixed-position notification snackbar (message snake).
148+
149+
Returns a Dash html.Div with fade-in animation, auto-dismiss timer,
150+
and optional close button. Must call register_message_snake_callbacks()
151+
to enable auto-dismiss and close functionality.
152+
153+
Args:
154+
message (str): The notification message to display.
155+
id (str): Unique HTML id for the notification container.
156+
msg_type (str): Notification type - 'info', 'warning', 'error', 'success'.
157+
position (str): Fixed position on screen. One of:
158+
'top', 'bottom', 'center', 'top-right', 'top-left',
159+
'bottom-right', 'bottom-left'.
160+
style (dict, optional): Additional CSS style overrides.
161+
show_icon (bool): Whether to show a type-specific icon.
162+
min_width (str): Minimum width of the notification.
163+
max_width (str): Maximum width of the notification.
164+
z_index (int): CSS z-index for layering.
165+
auto_dismiss_ms (int): Auto-dismiss delay in milliseconds. Defaults to 50000 (50s).
166+
167+
"""
168+
169+
self.snake_id = aio_id
170+
self.show_icon = show_icon
171+
self.msg_type = msg_type
172+
self.message = message
173+
self.auto_dismiss_ms = auto_dismiss_ms
174+
175+
# Resolve type colors
176+
type_style = _TYPE_COLORS.get(msg_type, _TYPE_COLORS["info"])
177+
178+
# Resolve position
179+
pos_style = _POSITION_STYLES.get(position, _POSITION_STYLES["bottom-right"])
180+
181+
# Build the notification style
182+
self.notification_style = {
183+
"position": "fixed",
184+
"zIndex": z_index,
185+
"minWidth": min_width,
186+
"maxWidth": max_width,
187+
"padding": "14px 20px",
188+
"borderRadius": "6px",
189+
"boxShadow": "0 4px 14px rgba(0, 0, 0, 0.2)",
190+
"display": "flex",
191+
"alignItems": "center",
192+
"gap": "12px",
193+
"fontFamily": "'Inter', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
194+
"fontSize": "18px",
195+
"fontWeight": "500",
196+
"lineHeight": "1.4",
197+
# Visible on mount; fade-out handled by callback via transition
198+
"opacity": "1",
199+
"transition": "opacity 0.4s ease",
200+
**type_style,
201+
**pos_style,
202+
}
203+
204+
# Apply user style overrides
205+
if style:
206+
self.notification_style.update(style)
207+
208+
# Define the component's layout, this is originally from `layout()` for MPComponent
209+
# But since this is AIO, which subclassing html.Div
210+
sub_layouts = self._sub_layouts
211+
super().__init__(
212+
[ # Equivalent to `html.Div([...])`
213+
dcc.Store(id=self.ids.visible(self.snake_id), data=False),
214+
sub_layouts["notification_div"],
215+
sub_layouts["interval"],
216+
],
217+
id=self.ids.wrapper(self.snake_id),
218+
style={"display": "none"},
219+
)
220+
221+
@property
222+
def _sub_layouts(self):
223+
# Build inner content
224+
children = []
225+
226+
# Icon
227+
if self.show_icon:
228+
icon_class = _TYPE_ICONS.get(self.msg_type, _TYPE_ICONS["info"])
229+
children.append(
230+
html.I(
231+
className=icon_class,
232+
style={"fontSize": "18px", "flexShrink": "0"},
233+
)
234+
)
235+
236+
# Message text
237+
children.append(
238+
html.Span(
239+
self.message, id=self.ids.message(self.snake_id), style={"flex": "1"}
240+
)
241+
)
242+
243+
# Close button
244+
children.append(
245+
html.Button(
246+
html.I(className="fas fa-times"),
247+
className="delete",
248+
style={
249+
"background": "transparent",
250+
"border": "none",
251+
"color": "inherit",
252+
"cursor": "pointer",
253+
"fontSize": "18px",
254+
"padding": "0",
255+
"marginLeft": "8px",
256+
"opacity": "0.8",
257+
"flexShrink": "0",
258+
},
259+
id=self.ids.close_button(self.snake_id),
260+
n_clicks=0,
261+
)
262+
)
263+
264+
# Notification div
265+
notification_div = html.Div(
266+
children=children,
267+
id=self.ids.div(self.snake_id),
268+
style=self.notification_style,
269+
)
270+
271+
# Auto-dismiss interval timer (fires once after auto_dismiss_ms)
272+
interval = dcc.Interval(
273+
id=self.ids.timer(self.snake_id),
274+
interval=self.auto_dismiss_ms,
275+
n_intervals=0,
276+
max_intervals=1,
277+
)
278+
279+
return {"notification_div": notification_div, "interval": interval}
280+
281+
"""
282+
def layout(self) -> html.Div:
283+
sub_layouts = self._sub_layouts
284+
return html.Div(
285+
[
286+
dcc.Store(id=self.ids.visible(self.snake_id), data=False),
287+
sub_layouts["notification_div"],
288+
sub_layouts["interval"],
289+
],
290+
id=self.ids.wrapper(self.snake_id),
291+
style={"display": "none"},
292+
)
293+
"""
294+
295+
@callback(
296+
Output(ids.wrapper(MATCH), "style"),
297+
Input(ids.visible(MATCH), "data"),
298+
Input(ids.close_button(MATCH), "n_clicks"),
299+
prevent_initial_call=True,
300+
)
301+
def sync_message(command_visible, close_clicks):
302+
triggered = ctx.triggered_id
303+
304+
if (
305+
isinstance(triggered, dict)
306+
and triggered.get("subcomponents") == "close_button"
307+
):
308+
return {"display": "none"}
309+
310+
return {"display": "block"} if command_visible else {"display": "none"}
311+
312+
"""
313+
@callback(
314+
Output(ids.wrapper(MATCH), "style"),
315+
Input(ids.timer(MATCH), "n_intervals"),
316+
Input(ids.close_button(MATCH), "n_clicks"),
317+
State(ids.wrapper(MATCH), "style"),
318+
prevent_initial_call=True,
319+
)
320+
def _dismiss_message_snake(n_intervals, n_clicks, current_style):
321+
# Fade out and hide the message snake on timer or close click.
322+
if not current_style:
323+
raise PreventUpdate
324+
325+
if not ctx.triggered:
326+
raise PreventUpdate
327+
328+
# Apply fade-out: transition opacity to 0, then hide
329+
new_style = {**current_style}
330+
new_style["transition"] = "opacity 0.4s ease"
331+
new_style["opacity"] = "0"
332+
new_style["pointerEvents"] = "none"
333+
# Override the fade-in animation so it does not reset
334+
new_style["animation"] = "none"
335+
return new_style
336+
"""

0 commit comments

Comments
 (0)