Skip to main content

tessera_components/
snackbar.rs

1//! Material Design snackbars for transient feedback messages.
2//!
3//! ## Usage
4//!
5//! Show brief status updates with optional actions at the bottom of a screen.
6
7use std::{collections::VecDeque, time::Duration};
8
9use tessera_ui::{
10    Callback, Color, Dp, FocusManager, FocusRestorationTarget, FocusScopeNode, Modifier,
11    RenderSlotWith, State,
12    accesskit::{Live, Role},
13    current_frame_nanos,
14    layout::layout,
15    modifier::FocusModifierExt as _,
16    provide_context, receive_frame_nanos, remember, tessera, use_context,
17};
18
19use crate::{
20    alignment::Alignment,
21    button::button,
22    icon_button::{IconButtonVariant, icon_button},
23    material_icons::filled,
24    modifier::{ModifierExt as _, Padding, SemanticsArgs, with_keyboard_input},
25    res::I18N,
26    shape_def::Shape,
27    surface::surface,
28    text::text,
29    theme::MaterialTheme,
30};
31
32const SHORT_SNACKBAR_DURATION: Duration = Duration::from_millis(4_000);
33const LONG_SNACKBAR_DURATION: Duration = Duration::from_millis(10_000);
34
35/// Possible results of a snackbar being shown.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum SnackbarResult {
38    /// The snackbar was dismissed (timeout or user dismissal).
39    Dismissed,
40    /// The snackbar action button was invoked.
41    ActionPerformed,
42}
43
44/// Duration options for snackbars.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
46pub enum SnackbarDuration {
47    /// Show for a short period.
48    #[default]
49    Short,
50    /// Show for a long period.
51    Long,
52    /// Show until dismissed explicitly.
53    Indefinite,
54}
55
56impl SnackbarDuration {
57    fn timeout(self) -> Option<Duration> {
58        match self {
59            SnackbarDuration::Short => Some(SHORT_SNACKBAR_DURATION),
60            SnackbarDuration::Long => Some(LONG_SNACKBAR_DURATION),
61            SnackbarDuration::Indefinite => None,
62        }
63    }
64
65    fn default_for_action(has_action: bool) -> Self {
66        if has_action {
67            SnackbarDuration::Indefinite
68        } else {
69            SnackbarDuration::Short
70        }
71    }
72}
73
74/// Request data for showing a snackbar with default behavior.
75#[derive(Clone, Debug)]
76pub struct SnackbarRequest {
77    /// Primary message shown in the snackbar.
78    pub message: String,
79    /// Optional label for the action button.
80    pub action_label: Option<String>,
81    /// Whether a dismiss action should be shown.
82    pub with_dismiss_action: bool,
83    /// Optional duration override for the snackbar.
84    pub duration: Option<SnackbarDuration>,
85}
86
87impl SnackbarRequest {
88    /// Creates a new request with the required message.
89    pub fn new(message: impl Into<String>) -> Self {
90        Self {
91            message: message.into(),
92            action_label: None,
93            with_dismiss_action: false,
94            duration: None,
95        }
96    }
97}
98
99impl From<String> for SnackbarRequest {
100    fn from(message: String) -> Self {
101        Self::new(message)
102    }
103}
104
105impl From<&str> for SnackbarRequest {
106    fn from(message: &str) -> Self {
107        Self::new(message)
108    }
109}
110
111#[derive(Clone, PartialEq, Debug)]
112struct ResolvedSnackbar {
113    message: String,
114    action_label: Option<String>,
115    with_dismiss_action: bool,
116    duration: SnackbarDuration,
117}
118
119impl From<SnackbarRequest> for ResolvedSnackbar {
120    fn from(request: SnackbarRequest) -> Self {
121        let action_label = request.action_label.filter(|label| !label.is_empty());
122        let has_action = action_label.is_some();
123        let duration = request
124            .duration
125            .unwrap_or_else(|| SnackbarDuration::default_for_action(has_action));
126        Self {
127            message: request.message,
128            action_label,
129            with_dismiss_action: request.with_dismiss_action,
130            duration,
131        }
132    }
133}
134
135/// Data describing the current snackbar shown by [`snackbar_host`].
136#[derive(Clone, PartialEq)]
137pub struct SnackbarData {
138    message: String,
139    action_label: Option<String>,
140    with_dismiss_action: bool,
141    duration: SnackbarDuration,
142    host_state: State<SnackbarHostState>,
143    id: u64,
144}
145
146impl SnackbarData {
147    fn new(record: SnackbarRecord, host_state: State<SnackbarHostState>) -> Self {
148        let SnackbarRecord { id, resolved } = record;
149        let ResolvedSnackbar {
150            message,
151            action_label,
152            with_dismiss_action,
153            duration,
154        } = resolved;
155        Self {
156            message,
157            action_label,
158            with_dismiss_action,
159            duration,
160            host_state,
161            id,
162        }
163    }
164
165    /// Returns the message displayed in the snackbar.
166    pub fn message(&self) -> &str {
167        &self.message
168    }
169
170    /// Returns the action label displayed in the snackbar, if any.
171    pub fn action_label(&self) -> Option<&str> {
172        self.action_label.as_deref()
173    }
174
175    /// Returns whether a dismiss action is shown.
176    pub fn with_dismiss_action(&self) -> bool {
177        self.with_dismiss_action
178    }
179
180    /// Returns the resolved duration for the snackbar.
181    pub fn duration(&self) -> SnackbarDuration {
182        self.duration
183    }
184
185    /// Report that the snackbar action was performed.
186    pub fn perform_action(&self) {
187        self.host_state.with_mut(|state| {
188            state.resolve_current(self.id, SnackbarResult::ActionPerformed);
189        });
190    }
191
192    /// Dismiss the snackbar.
193    pub fn dismiss(&self) {
194        self.host_state.with_mut(|state| {
195            state.resolve_current(self.id, SnackbarResult::Dismissed);
196        });
197    }
198}
199
200#[derive(Clone, PartialEq)]
201struct SnackbarRecord {
202    id: u64,
203    resolved: ResolvedSnackbar,
204}
205
206/// State container for managing snackbar queues.
207pub struct SnackbarHostState {
208    queue: VecDeque<SnackbarRecord>,
209    current: Option<SnackbarRecord>,
210    current_started_frame_nanos: Option<u64>,
211    next_id: u64,
212    last_result: Option<SnackbarResult>,
213}
214
215impl SnackbarHostState {
216    /// Creates a new empty snackbar host state.
217    pub fn new() -> Self {
218        Self {
219            queue: VecDeque::new(),
220            current: None,
221            current_started_frame_nanos: None,
222            next_id: 1,
223            last_result: None,
224        }
225    }
226
227    /// Enqueue a snackbar with default behavior.
228    ///
229    /// Returns the unique snackbar id.
230    pub fn show_snackbar(&mut self, request: impl Into<SnackbarRequest>) -> u64 {
231        let resolved: ResolvedSnackbar = request.into().into();
232        let id = self.next_id;
233        self.next_id = self.next_id.wrapping_add(1);
234        self.queue.push_back(SnackbarRecord { id, resolved });
235        if self.current.is_none() {
236            self.advance_queue();
237        }
238        id
239    }
240
241    /// Returns whether a snackbar is currently visible.
242    pub fn is_showing(&self) -> bool {
243        self.current.is_some()
244    }
245
246    /// Dismisses the current snackbar, if any.
247    pub fn dismiss_current(&mut self) {
248        if let Some(current) = &self.current {
249            self.resolve_current(current.id, SnackbarResult::Dismissed);
250        }
251    }
252
253    /// Returns the last snackbar result and clears it.
254    pub fn take_last_result(&mut self) -> Option<SnackbarResult> {
255        self.last_result.take()
256    }
257
258    fn poll(&mut self, frame_nanos: u64) -> Option<SnackbarRecord> {
259        if self.current.is_none() {
260            self.advance_queue();
261        }
262
263        if self.current.is_some() && self.current_started_frame_nanos.is_none() {
264            self.current_started_frame_nanos = Some(frame_nanos);
265        }
266
267        let mut should_dismiss = false;
268        if let Some(current) = &self.current
269            && let Some(timeout) = current.resolved.duration.timeout()
270            && let Some(started_frame_nanos) = self.current_started_frame_nanos
271        {
272            let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
273            let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
274            if elapsed_nanos >= timeout_nanos {
275                should_dismiss = true;
276            }
277        }
278
279        if should_dismiss && let Some(current) = &self.current {
280            self.resolve_current(current.id, SnackbarResult::Dismissed);
281        }
282
283        self.current.clone()
284    }
285
286    fn has_pending_timeout(&self, frame_nanos: u64) -> bool {
287        let Some(current) = &self.current else {
288            return false;
289        };
290        let Some(timeout) = current.resolved.duration.timeout() else {
291            return false;
292        };
293
294        self.current_started_frame_nanos
295            .map(|started_frame_nanos| {
296                let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
297                let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
298                elapsed_nanos < timeout_nanos
299            })
300            .unwrap_or(true)
301    }
302
303    fn should_dismiss_current_timeout(&self, frame_nanos: u64) -> bool {
304        let Some(current) = &self.current else {
305            return false;
306        };
307        let Some(timeout) = current.resolved.duration.timeout() else {
308            return false;
309        };
310        let Some(started_frame_nanos) = self.current_started_frame_nanos else {
311            return false;
312        };
313
314        let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
315        let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
316        elapsed_nanos >= timeout_nanos
317    }
318
319    fn should_poll(&self, frame_nanos: u64) -> bool {
320        if self.current.is_none() {
321            return !self.queue.is_empty();
322        }
323
324        if self.current_started_frame_nanos.is_none() {
325            return true;
326        }
327
328        self.should_dismiss_current_timeout(frame_nanos)
329    }
330
331    fn resolve_current(&mut self, id: u64, result: SnackbarResult) -> bool {
332        if self.current.as_ref().map(|record| record.id) != Some(id) {
333            return false;
334        }
335        self.last_result = Some(result);
336        self.advance_queue();
337        true
338    }
339
340    fn advance_queue(&mut self) {
341        self.current = self.queue.pop_front();
342        self.current_started_frame_nanos = None;
343    }
344}
345
346impl Default for SnackbarHostState {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352/// Default values used by snackbars.
353pub struct SnackbarDefaults;
354
355impl SnackbarDefaults {
356    /// Maximum width for snackbar containers.
357    pub const MAX_WIDTH: Dp = Dp(600.0);
358    /// Minimum height for single-line snackbars.
359    pub const MIN_HEIGHT_ONE_LINE: Dp = Dp(48.0);
360    /// Minimum height for multi-line snackbars.
361    pub const MIN_HEIGHT_TWO_LINE: Dp = Dp(68.0);
362    /// Default container elevation.
363    pub const CONTAINER_ELEVATION: Dp = Dp(6.0);
364    /// Default padding around snackbar message text.
365    pub const CONTENT_PADDING: Padding = Padding::symmetric(Dp(16.0), Dp(14.0));
366    /// Default padding applied when rendering snackbars from host data.
367    pub const HOST_PADDING: Padding = Padding::all(Dp(12.0));
368    /// Horizontal spacing between text and actions.
369    pub const ACTION_SPACING: Dp = Dp(8.0);
370    /// Bottom spacing below actions placed on a separate line.
371    pub const ACTION_VERTICAL_SPACING: Dp = Dp(4.0);
372
373    /// Default snackbar shape.
374    pub fn shape() -> crate::shape_def::Shape {
375        crate::shape_def::Shape::RoundedRectangle {
376            top_left: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
377            top_right: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
378            bottom_right: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
379            bottom_left: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
380        }
381    }
382
383    /// Default container color derived from the current theme.
384    pub fn container_color() -> Color {
385        Color::new(0.1, 0.1, 0.1, 1.0)
386    }
387
388    /// Default content color derived from the current theme.
389    pub fn content_color() -> Color {
390        Color::new(0.95, 0.95, 0.95, 1.0)
391    }
392
393    /// Default action label color derived from the current theme.
394    pub fn action_color() -> Color {
395        Color::new(0.7, 0.8, 1.0, 1.0)
396    }
397
398    /// Default dismiss action color derived from the current theme.
399    pub fn dismiss_action_color() -> Color {
400        Color::new(0.95, 0.95, 0.95, 1.0)
401    }
402
403    /// Returns the minimum height for explicit message lines and action
404    /// placement.
405    ///
406    /// Measured wrapping, larger typography and taller actions may require
407    /// more.
408    pub fn min_height(message: &str, action_on_new_line: bool) -> Dp {
409        let message_height = if message.contains('\n') {
410            Self::MIN_HEIGHT_TWO_LINE
411        } else {
412            Self::MIN_HEIGHT_ONE_LINE
413        };
414        if action_on_new_line {
415            Dp(message_height.0 + 48.0 + Self::ACTION_VERTICAL_SPACING.0)
416        } else {
417            message_height
418        }
419    }
420}
421
422/// # snackbar
423///
424/// Show a brief message with optional action and dismiss controls.
425///
426/// ## Usage
427///
428/// Display transient feedback or status updates that do not interrupt the
429/// workflow.
430///
431/// ## Parameters
432///
433/// - `modifier` — modifier chain applied to the snackbar container.
434/// - `message` — message shown in the snackbar.
435/// - `action_label` — optional label for the action button.
436/// - `with_dismiss_action` — whether to show a dismiss action icon.
437/// - `action_on_new_line` — whether the action should be placed on a new line.
438/// - `shape` — optional shape override.
439/// - `container_color` — optional container color override.
440/// - `content_color` — optional message color override.
441/// - `action_color` — optional action color override.
442/// - `dismiss_action_color` — optional dismiss action color override.
443/// - `content_padding` — optional message insets; vertical padding excludes
444///   action controls. Explicit horizontal insets also bound the action row.
445/// - `dismiss_action_label` — accessible close-button name; defaults to
446///   Dismiss.
447/// - `on_action` — optional action callback.
448/// - `on_dismiss` — optional dismiss callback, also invoked when Escape is
449///   pressed while one of the snackbar controls has focus.
450///
451/// ## Examples
452///
453/// ```
454/// use tessera_components::snackbar::snackbar;
455/// use tessera_components::theme::{MaterialTheme, material_theme};
456/// use tessera_ui::tessera;
457///
458/// #[tessera]
459/// fn demo() {
460///     material_theme()
461///         .theme(|| MaterialTheme::default())
462///         .child(|| {
463///             snackbar()
464///                 .message("Saved")
465///                 .action_label("Undo")
466///                 .with_dismiss_action(true)
467///                 .on_action(|| {})
468///                 .on_dismiss(|| {});
469///         });
470/// }
471///
472/// # tessera_ui::testing::layout_test(demo).run();
473/// ```
474#[tessera]
475pub fn snackbar(
476    modifier: Option<Modifier>,
477    #[prop(into)] message: Option<String>,
478    #[prop(into)] action_label: Option<String>,
479    with_dismiss_action: Option<bool>,
480    action_on_new_line: Option<bool>,
481    shape: Option<Shape>,
482    container_color: Option<Color>,
483    content_color: Option<Color>,
484    action_color: Option<Color>,
485    dismiss_action_color: Option<Color>,
486    #[prop(into)] dismiss_action_label: Option<String>,
487    content_padding: Option<Padding>,
488    on_action: Option<Callback>,
489    on_dismiss: Option<Callback>,
490) {
491    let modifier = modifier.unwrap_or_default();
492    let message = message.unwrap_or_default();
493    let with_dismiss_action = with_dismiss_action.unwrap_or(false);
494    let action_on_new_line = action_on_new_line.unwrap_or(false);
495    let theme = use_context::<MaterialTheme>()
496        .expect("MaterialTheme must be provided")
497        .get();
498    let typography = theme.typography;
499    let scheme = theme.color_scheme;
500    let shape = shape.unwrap_or(theme.shapes.extra_small);
501    let container_color = container_color.unwrap_or(scheme.inverse_surface);
502    let content_color = content_color.unwrap_or(scheme.inverse_on_surface);
503    let action_color = action_color.unwrap_or(scheme.inverse_primary);
504    let dismiss_action_color = dismiss_action_color.unwrap_or(scheme.inverse_on_surface);
505    let dismiss_action_label =
506        dismiss_action_label.unwrap_or_else(|| I18N.text("md3-dismiss").into());
507    let action_label = action_label.filter(|label| !label.is_empty());
508    let has_action = action_label.is_some();
509    let action_on_new_line = action_on_new_line && has_action;
510    let modifier = modifier.size_in(None, Some(SnackbarDefaults::MAX_WIDTH), None, None);
511    let on_action = if has_action {
512        Some(on_action.unwrap_or_default())
513    } else {
514        None
515    };
516    let show_dismiss_action = with_dismiss_action || on_dismiss.is_some();
517    let on_dismiss = if show_dismiss_action {
518        Some(on_dismiss.unwrap_or_default())
519    } else {
520        None
521    };
522    let modifier = if let Some(on_dismiss) = on_dismiss {
523        with_keyboard_input(modifier, move |input: tessera_ui::KeyboardInput<'_>| {
524            input
525                .keyboard_events
526                .retain(|event| !dismiss_key(event.physical_key, event.state, on_dismiss));
527        })
528    } else {
529        modifier
530    };
531
532    surface()
533        .modifier(modifier)
534        .style(container_color.into())
535        .shape(shape)
536        .content_alignment(if action_on_new_line {
537            Alignment::TopStart
538        } else {
539            Alignment::CenterStart
540        })
541        .block_input(true)
542        .content_color(content_color)
543        .elevation(SnackbarDefaults::CONTAINER_ELEVATION)
544        .child(move || {
545            let message = message.clone();
546            let action_label = action_label.clone();
547            let dismiss_action_label = dismiss_action_label.clone();
548            layout()
549                .policy(snackbar_layout::SnackbarLayout {
550                    stacked: action_on_new_line,
551                    action: has_action,
552                    dismiss: show_dismiss_action,
553                    padding: content_padding,
554                })
555                .child(move || {
556                    provide_context(|| typography.body_medium, {
557                        let message = message.clone();
558                        move || {
559                            text(message.clone())
560                                .modifier(Modifier::new().semantics(SemanticsArgs {
561                                    role: Some(Role::Status),
562                                    live: Some(Live::Polite),
563                                    ..Default::default()
564                                }))
565                                .color(content_color);
566                        }
567                    });
568                    if let Some(label) = action_label.clone() {
569                        button()
570                            .text()
571                            .accessibility_label(label.clone())
572                            .on_click_shared(on_action.unwrap_or_default())
573                            .content_color(action_color)
574                            .ripple_color(action_color)
575                            .child(move || {
576                                text(label.clone()).color(action_color);
577                            });
578                    }
579                    if let Some(on_dismiss) = on_dismiss {
580                        icon_button()
581                            .icon(filled::CLOSE_SVG)
582                            .accessibility_label(dismiss_action_label.clone())
583                            .variant(IconButtonVariant::Standard)
584                            .content_color(dismiss_action_color)
585                            .on_click_shared(on_dismiss);
586                    }
587                });
588        });
589}
590
591fn dismiss_key(
592    key: tessera_ui::winit::keyboard::PhysicalKey,
593    state: tessera_ui::winit::event::ElementState,
594    on_dismiss: Callback,
595) -> bool {
596    if state == tessera_ui::winit::event::ElementState::Pressed
597        && key
598            == tessera_ui::winit::keyboard::PhysicalKey::Code(
599                tessera_ui::winit::keyboard::KeyCode::Escape,
600            )
601    {
602        on_dismiss.call();
603        true
604    } else {
605        false
606    }
607}
608
609/// # snackbar_host
610///
611/// Display queued snackbars driven by a [`SnackbarHostState`].
612///
613/// ## Usage
614///
615/// Use with [`scaffold`](crate::scaffold::scaffold) to show transient messages
616/// above app content.
617///
618/// ## Parameters
619///
620/// - `modifier` — modifier chain applied to the host container.
621/// - `state` — state that provides snackbar queue data.
622/// - `snackbar` — optional custom snackbar slot for rendering.
623///
624/// Appearance preserves the current focus. When the final notification closes
625/// while its controls own focus, the host restores the previously focused
626/// target after layout if that target remains available. Focus moved outside
627/// the host is preserved.
628///
629/// ## Examples
630///
631/// ```
632/// use tessera_components::snackbar::{SnackbarHostState, snackbar_host};
633/// use tessera_components::theme::{MaterialTheme, material_theme};
634/// use tessera_ui::{remember, tessera};
635///
636/// #[tessera]
637/// fn demo() {
638///     material_theme()
639///         .theme(|| MaterialTheme::default())
640///         .child(|| {
641///             let host_state = remember(SnackbarHostState::new);
642///             host_state.with_mut(|state| {
643///                 state.show_snackbar("Saved");
644///             });
645///             snackbar_host().state(host_state);
646///             assert!(host_state.with(|state| state.is_showing()));
647///         });
648/// }
649///
650/// # tessera_ui::testing::layout_test(demo).run();
651/// ```
652#[tessera]
653pub fn snackbar_host(
654    modifier: Option<Modifier>,
655    state: Option<State<SnackbarHostState>>,
656    snackbar: Option<RenderSlotWith<SnackbarData>>,
657) {
658    let modifier = modifier.unwrap_or_default();
659    let state = match state {
660        Some(value) => value,
661        None => remember(SnackbarHostState::default),
662    };
663    let snackbar_slot = snackbar;
664    let frame_nanos = current_frame_nanos();
665    let should_poll = state.with(|host| host.should_poll(frame_nanos));
666    let record = if should_poll {
667        state.with_mut(|host| host.poll(frame_nanos))
668    } else {
669        state.with(|host| host.current.clone())
670    };
671    if state.with(|host| host.has_pending_timeout(frame_nanos)) {
672        receive_frame_nanos(move |frame_nanos| {
673            let should_dismiss =
674                state.with(|host| host.should_dismiss_current_timeout(frame_nanos));
675            if should_dismiss {
676                state.with_mut(|host| {
677                    let _ = host.poll(frame_nanos);
678                });
679            }
680            let has_pending_timeout = state.with(|host| host.has_pending_timeout(frame_nanos));
681            if has_pending_timeout {
682                tessera_ui::FrameNanosControl::Continue
683            } else {
684                tessera_ui::FrameNanosControl::Stop
685            }
686        });
687    }
688    let scope = remember(FocusScopeNode::new).get();
689    let showing = remember(|| false);
690    let origin = remember(|| None::<FocusRestorationTarget>);
691    let pending_restore = remember(|| None::<FocusRestorationTarget>);
692    let visible = record.is_some();
693    if visible != showing.get() {
694        if visible {
695            origin.set(FocusManager::current().save_focus());
696        } else {
697            if scope.has_focus() {
698                pending_restore.set(origin.get());
699                FocusManager::current().defer_keyboard_until_layout();
700            }
701            origin.set(None);
702        }
703        showing.set(visible);
704    }
705    let mut modifier = modifier.focus_scope_with(scope);
706    if pending_restore.get().is_some() {
707        modifier = modifier.on_globally_positioned(move |_| {
708            if let Some(target) = pending_restore.with_mut(Option::take) {
709                target.restore();
710            }
711        });
712    }
713    let data = record.map(|record| SnackbarData::new(record, state));
714    layout().modifier(modifier).child(move || {
715        let Some(data) = data.clone() else {
716            return;
717        };
718        if let Some(snackbar_slot) = snackbar_slot {
719            snackbar_slot.render(data.clone());
720        } else {
721            layout()
722                .modifier(Modifier::new().padding(SnackbarDefaults::HOST_PADDING))
723                .child(move || {
724                    snackbar_from_data().data(data.clone());
725                });
726        }
727    });
728}
729
730#[tessera]
731fn snackbar_from_data(data: Option<SnackbarData>) {
732    let Some(data) = data else {
733        return;
734    };
735    let current = remember(|| data.clone());
736    if current.get() != data {
737        current.set(data.clone());
738    }
739    let on_action = if data.action_label.is_some() {
740        Some(Callback::new(move || current.get().perform_action()))
741    } else {
742        None
743    };
744    let on_dismiss = if data.with_dismiss_action {
745        Some(Callback::new(move || current.get().dismiss()))
746    } else {
747        None
748    };
749    snackbar()
750        .message(data.message)
751        .with_dismiss_action(data.with_dismiss_action)
752        .action_label_optional(data.action_label)
753        .on_action_optional(on_action)
754        .on_dismiss_optional(on_dismiss);
755}
756
757#[cfg(test)]
758mod accessibility_tests;
759
760#[cfg(test)]
761mod host_tests;
762
763mod snackbar_layout;
764
765#[cfg(test)]
766mod geometry_tests;