Skip to main content

teksilo_widgets/
snackbar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Snackbar — a transient, button-triggered floating notification surface.
5//!
6//! A `Snackbar` pairs a trigger (a `Button` by default, or any custom
7//! widget via `.trigger(...)`) with a dormant content surface. Activating
8//! the trigger presents the surface as an `OverlayPlacement::BottomCenter`
9//! overlay and dismisses it automatically after a configurable timeout
10//! (default: 4 s). The surface stays until dismissed when `.persistent()`
11//! is set. Only one snackbar can be shown at a time — presenting a second
12//! one dismisses the first.
13//!
14//! For richer, stackable, severity-aware notifications see the
15//! [`Toast`](crate::toast::Toast) system, which also maintains a
16//! persistent `NotificationArchiveModel`.
17//!
18//! ## Accessibility
19//!
20//! The content surface exposes `Role::Alert` with `Live::Polite` so
21//! screen readers announce the notification without interrupting the user.
22//! Supply `.announcement(...)` to give the alert a descriptive name
23//! instead of the generic "notification" fallback.
24//!
25//! ```ignore
26//! use teksilo_widgets::{Snackbar};
27//! use teksilo_i18n::lit;
28//! use teksilo_widgets::primitives::TextWidget;
29//! use teksilo_tokens::TextRole;
30//!
31//! // In build():
32//! ctx.add(
33//!     Snackbar::new(lit!("Undo"))
34//!         .content(TextWidget::new(lit!("File deleted.")).color(TextRole::TooltipText))
35//!         .announcement(lit!("File deleted."))
36//!         .auto_dismiss_after(std::time::Duration::from_secs(5)),
37//! );
38//! ```
39
40use std::rc::Rc;
41use std::time::Duration;
42
43use teksilo_canvas::{Rect, SizeProposal};
44use teksilo_core::accessibility::AccessNodeBuilder;
45use teksilo_core::build_context::BuildContext;
46use teksilo_core::event::{EventResponse, Key, WidgetEvent};
47use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
48use teksilo_core::signal::Prop;
49use teksilo_core::styles::{SharedSnackbarStyle, SnackbarStyleConfig};
50use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
51use teksilo_core::widget_id::WidgetId;
52
53use crate::button::{Button, ButtonVariant};
54use crate::overlay_trigger::OverlayTrigger;
55use teksilo_i18n::LocalizedString;
56
57const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(4);
58
59fn present_snackbar(
60    ctx: &mut teksilo_core::widget::EventContext,
61    anchor: WidgetId,
62    content_id: WidgetId,
63    shown: &teksilo_core::signal::Signal<bool>,
64    dismiss: DismissBehavior,
65    auto_dismiss_after: Option<Duration>,
66    fade_duration: Option<Duration>,
67) {
68    ctx.dismiss_all_except_hosts();
69    // Build the surface if this is the first time this snackbar is presented —
70    // `activate` alone would wake a node whose subtree does not exist yet.
71    shown.set(true);
72    ctx.materialize_now(content_id);
73    ctx.activate(content_id);
74    let request = OverlayRequest {
75        content_id,
76        anchor,
77        placement: OverlayPlacement::BottomCenter,
78        dismiss,
79        layer: OverlayLayer::InTree,
80        parent_overlay: None,
81        on_dismiss: None,
82        fade_duration,
83    };
84    if let Some(duration) = auto_dismiss_after {
85        ctx.show_overlay_for(request, duration);
86    } else {
87        ctx.show_overlay(request);
88    }
89}
90
91struct SnackbarSurface {
92    content_id: Option<WidgetId>,
93    pending_content: Option<PendingChild>,
94    /// Optional explicit SR announcement string. When set,
95    /// `accessibility()` uses it as the Alert's accessible name
96    /// so screen readers read out the caller-provided message
97    /// the moment the snackbar appears. Falls back to the
98    /// generic `a11y_snackbar_name` when unset.
99    announcement: Option<LocalizedString>,
100    /// Per-call override for the snackbar surface chrome.
101    style_override: Option<SharedSnackbarStyle>,
102    /// Build state — the `SnackbarStyle::make_body` root.
103    root_child_id: Option<WidgetId>,
104}
105
106impl SnackbarSurface {
107    fn new(content: PendingChild) -> Self {
108        Self {
109            content_id: None,
110            pending_content: Some(content),
111            announcement: None,
112            style_override: None,
113            root_child_id: None,
114        }
115    }
116
117    fn with_announcement(mut self, text: Option<LocalizedString>) -> Self {
118        self.announcement = text;
119        self
120    }
121
122    fn with_style(mut self, style: Option<SharedSnackbarStyle>) -> Self {
123        self.style_override = style;
124        self
125    }
126}
127
128impl std::fmt::Debug for SnackbarSurface {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("SnackbarSurface").finish()
131    }
132}
133
134impl Widget for SnackbarSurface {
135    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
136        if let Some(pending) = self.pending_content.take() {
137            self.content_id = Some(match pending {
138                PendingChild::Id(id) => id,
139                PendingChild::Deferred(w) => ctx.add_boxed(w),
140            });
141        }
142        // The surface chrome (dark `tooltip_bg` panel + border + padding
143        // inset) is owned by the active `SnackbarStyle`; this widget
144        // keeps its `Role::Alert` / `Live::Polite` accessibility node.
145        let content_id = self
146            .content_id
147            .expect("SnackbarSurface requires content — none was set");
148        let style: SharedSnackbarStyle = self
149            .style_override
150            .clone()
151            .or_else(|| ctx.theme().style_slots.snackbar.clone())
152            .unwrap_or_else(|| Rc::new(crate::styles::RecipeSnackbarStyle::default()));
153        let root_id = style.make_body(
154            &SnackbarStyleConfig {
155                content: content_id,
156            },
157            ctx,
158        );
159        self.root_child_id = Some(root_id);
160        vec![root_id]
161    }
162
163    fn layout_response(
164        &self,
165        proposal: SizeProposal,
166        ctx: &LayoutContext,
167    ) -> teksilo_core::widget::LayoutResponse {
168        self.root_child_id
169            .and_then(|id| ctx.child_size(id, proposal))
170            .unwrap_or_else(|| proposal.resolve(220.0, 44.0))
171            .into()
172    }
173
174    fn place_children(
175        &self,
176        bounds: Rect,
177        _proposal: SizeProposal,
178        children: &mut [WidgetPlacement],
179        _ctx: &LayoutContext,
180    ) {
181        for child in children.iter_mut() {
182            child.origin = bounds.origin();
183            child.size = bounds.size();
184        }
185    }
186
187    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
188        // Role::Alert + Live::Polite mirrors the ARIA pattern for
189        // transient notifications: screen readers announce the
190        // contents when the snackbar appears, without interrupting
191        // the user's current action. The accessible name is the
192        // caller-supplied announcement when present, otherwise
193        // the generic fallback. Child widgets still contribute
194        // their own nodes for full context.
195        builder.set_role(teksilo_core::accesskit::Role::Alert);
196        builder.set_live(teksilo_core::accesskit::Live::Polite);
197        let name = self
198            .announcement
199            .as_ref()
200            .map(|a| a.resolve_now())
201            .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_snackbar_name()).resolve_now());
202        builder.set_name(name);
203    }
204
205    fn children(&self) -> Vec<WidgetId> {
206        self.root_child_id.into_iter().collect()
207    }
208}
209
210/// A button-triggered transient notification surface.
211///
212/// Call `.content(...)` to supply the notification body, then add the
213/// widget to the tree. The trigger label is shown as a `Button` (or a
214/// custom widget via `.trigger(...)`); activating it presents the
215/// content surface at the bottom center of the window.
216pub struct Snackbar {
217    label: LocalizedString,
218    variant: ButtonVariant,
219    /// Enabled state (static or reactive). Wired into the arena on the
220    /// trigger node -- the default `Button` and, on the `.trigger(...)`
221    /// path, the `OverlayTrigger` -- so a disabled trigger greys out,
222    /// reports `disabled` to AT, and has its dispatch gated. The snapshot
223    /// read in `build()` is a redundant early-out kept in the custom-trigger
224    /// closures.
225    enabled: Prop<bool>,
226    dismiss: DismissBehavior,
227    auto_dismiss_after: Option<Duration>,
228    pending_content: Option<PendingChild>,
229    pending_trigger: Option<PendingChild>,
230    /// Optional explicit announcement string threaded through to
231    /// the `SnackbarSurface`'s a11y node. When set, screen readers
232    /// read this as the Alert's name when the snackbar appears.
233    announcement: Option<LocalizedString>,
234    /// Per-call override for the snackbar surface chrome.
235    style_override: Option<SharedSnackbarStyle>,
236    root_child_id: Option<WidgetId>,
237}
238
239impl Snackbar {
240    /// Create a snackbar whose default trigger button shows `label`.
241    pub fn new(label: impl Into<LocalizedString>) -> Self {
242        let ls: LocalizedString = label.into();
243        Self {
244            label: ls,
245            variant: ButtonVariant::Plain,
246            enabled: Prop::Static(true),
247            dismiss: DismissBehavior::ClickOutside,
248            auto_dismiss_after: Some(DEFAULT_AUTO_DISMISS),
249            pending_content: None,
250            pending_trigger: None,
251            announcement: None,
252            style_override: None,
253            root_child_id: None,
254        }
255    }
256
257    /// Per-call style override for the snackbar surface chrome.
258    /// Replaces the theme-wide default `SnackbarStyle` for just this
259    /// instance.
260    pub fn style(mut self, style: impl teksilo_core::styles::SnackbarStyle) -> Self {
261        self.style_override = Some(Rc::new(style));
262        self
263    }
264
265    /// The snackbar body — the message (and optional inline action)
266    /// shown on the floating surface.
267    ///
268    /// The default surface is the high-contrast (dark) `tooltip_bg`,
269    /// the same one tooltips use, and it stays dark in light theme.
270    /// So any `TextWidget` you pass here must set
271    /// `.color(TextRole::TooltipText)` (and actions can use
272    /// `TooltipText` / `TooltipShortcut`) — the default `TextRole::Primary`
273    /// is dark and renders nearly invisible on the dark surface in light
274    /// theme. If you install a light-surface `SnackbarStyle`, color the
275    /// content to match that instead.
276    pub fn content(mut self, content: impl Widget + 'static) -> Self {
277        self.pending_content = Some(PendingChild::Deferred(Box::new(content)));
278        self
279    }
280
281    /// Supply the notification body by `WidgetId` (already added to the
282    /// tree). Mutually exclusive with `.content(...)`.
283    pub fn content_id(mut self, id: WidgetId) -> Self {
284        self.pending_content = Some(PendingChild::Id(id));
285        self
286    }
287
288    /// Override the default trigger [`ButtonVariant`] (default: `Plain`).
289    pub fn variant(mut self, variant: ButtonVariant) -> Self {
290        self.variant = variant;
291        self
292    }
293
294    /// Set the enabled state of the trigger, statically or reactively.
295    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
296        self.enabled = enabled.into();
297        self
298    }
299
300    /// Override the overlay dismiss behavior (default: `ClickOutside`).
301    pub fn dismiss_behavior(mut self, dismiss: DismissBehavior) -> Self {
302        self.dismiss = dismiss;
303        self
304    }
305
306    /// Set the auto-dismiss timeout. The overlay is removed after this
307    /// duration without user interaction (default: 4 s).
308    pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
309        self.auto_dismiss_after = Some(duration);
310        self
311    }
312
313    /// Keep the snackbar visible until explicitly dismissed; disables
314    /// the auto-dismiss timeout.
315    pub fn persistent(mut self) -> Self {
316        self.auto_dismiss_after = None;
317        self
318    }
319
320    /// Replace the default `Button` trigger with a custom widget. The
321    /// widget is wired for tap, keyboard (Enter/Space), and AT Click
322    /// activation automatically.
323    pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self {
324        self.pending_trigger = Some(PendingChild::Deferred(Box::new(trigger)));
325        self
326    }
327
328    /// Supply the custom trigger by `WidgetId` (already added to the tree).
329    pub fn trigger_id(mut self, id: WidgetId) -> Self {
330        self.pending_trigger = Some(PendingChild::Id(id));
331        self
332    }
333
334    /// Screen-reader announcement string — used as the Alert's
335    /// accessible name when the snackbar appears. Without this
336    /// the surface falls back to the generic `a11y_snackbar_name`
337    /// i18n string, which says "notification" but can't describe
338    /// the specific message. Set this whenever the snackbar
339    /// conveys information the user needs to hear (errors,
340    /// confirmations, status changes).
341    pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self {
342        let ls: LocalizedString = text.into();
343        self.announcement = Some(ls);
344        self
345    }
346}
347
348impl std::fmt::Debug for Snackbar {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        f.debug_struct("Snackbar")
351            .field("label", &self.label)
352            .field("style", &self.variant)
353            .field("enabled", &self.enabled.get())
354            .finish()
355    }
356}
357
358impl Widget for Snackbar {
359    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
360        let self_id = ctx.self_id();
361        let label = self.label.clone();
362        // Redundant early-out for the custom-trigger closures below; the
363        // arena.s `enabled_when` (wired on the OverlayTrigger) already gates
364        // dispatch, so this snapshot is belt-and-suspenders.
365        let enabled = self.enabled.get();
366        let dismiss = self.dismiss.clone();
367        let auto_dismiss_after = self.auto_dismiss_after;
368        let style = self.variant;
369        // Captured at build time so the present-snackbar handlers
370        // don't need a theme lookup at fire-time. `duration_normal`
371        // matches the snackbar's typical "notification slide"
372        // recommendation in MotionTokens.
373        let fade_duration = if ctx.prefers_reduced_motion() {
374            None
375        } else {
376            Some(ctx.theme().motion.duration_normal)
377        };
378        // The surface is built the first time the snackbar is presented, not on
379        // every rebuild of the trigger that presents it. See
380        // `teksilo_core::deferred_subtree::DeferredSubtree`.
381        let shown = ctx.signal(false);
382        let content_id = ctx.add_detached_deferred(
383            shown.clone(),
384            SnackbarSurface::new(
385                self.pending_content
386                    .take()
387                    .expect("Snackbar requires .content(...) — no content was set"),
388            )
389            .with_announcement(self.announcement.clone())
390            .with_style(self.style_override.clone()),
391        );
392        ctx.set_dormant(content_id);
393        // The surface is shown through an overlay, so it stays out of the
394        // child walk — but `add_detached` above still records who owns it, so
395        // it is reaped with this Snackbar rather than stranded.
396
397        let root_id = if let Some(trigger) = self.pending_trigger.take() {
398            // A custom trigger is an arbitrary widget with no built-in
399            // activation, so we wire pointer / keyboard / AT activation
400            // by hand. (The default-Button branch below delegates all
401            // three to `Button::on_activate_fn`.)
402            let open_on_tap = {
403                let dismiss = dismiss.clone();
404                let shown = shown.clone();
405                move |_event: &teksilo_core::TapEvent,
406                      ctx: &mut teksilo_core::widget::EventContext| {
407                    if !enabled {
408                        return;
409                    }
410                    present_snackbar(
411                        ctx,
412                        self_id,
413                        content_id,
414                        &shown,
415                        dismiss.clone(),
416                        auto_dismiss_after,
417                        fade_duration,
418                    );
419                }
420            };
421            let handlers = teksilo_core::widget_builder::HandlerSet::new()
422                .focusable(true)
423                .cursor(teksilo_core::widget::CursorIcon::Pointer)
424                .on_tap(open_on_tap)
425                .on_key({
426                    let dismiss = dismiss.clone();
427                    let shown = shown.clone();
428                    move |event, ctx| match event {
429                        WidgetEvent::KeyUp {
430                            key: Key::Enter | Key::Space,
431                            ..
432                        } if enabled => {
433                            present_snackbar(
434                                ctx,
435                                self_id,
436                                content_id,
437                                &shown,
438                                dismiss.clone(),
439                                auto_dismiss_after,
440                                fade_duration,
441                            );
442                            EventResponse::Handled
443                        }
444                        _ => EventResponse::Ignored,
445                    }
446                })
447                .on_access_action({
448                    let shown = shown.clone();
449                    move |action, ctx| {
450                        if action == teksilo_core::accesskit::Action::Click && enabled {
451                            present_snackbar(
452                                ctx,
453                                self_id,
454                                content_id,
455                                &shown,
456                                dismiss.clone(),
457                                auto_dismiss_after,
458                                fade_duration,
459                            );
460                            EventResponse::Handled
461                        } else {
462                            EventResponse::Ignored
463                        }
464                    }
465                });
466            let overlay_trigger = match trigger {
467                PendingChild::Id(id) => OverlayTrigger::from_id(id, handlers),
468                PendingChild::Deferred(widget) => OverlayTrigger::new(widget, handlers),
469            }
470            .enabled(self.enabled.clone())
471            .name(label);
472            ctx.add(overlay_trigger)
473        } else {
474            // `Button::on_activate_fn` already fires on pointer tap,
475            // Space/Enter (with the matched-KeyDown guard), and AccessKit
476            // Click — so one handler covers all three activation paths.
477            ctx.add(
478                Button::new(label)
479                    .variant(style)
480                    .enabled(self.enabled.clone())
481                    .on_activate_fn(move |ctx| {
482                        present_snackbar(
483                            ctx,
484                            self_id,
485                            content_id,
486                            &shown,
487                            dismiss.clone(),
488                            auto_dismiss_after,
489                            fade_duration,
490                        );
491                    }),
492            )
493        };
494
495        self.root_child_id = Some(root_id);
496        vec![root_id]
497    }
498
499    fn layout_response(
500        &self,
501        proposal: SizeProposal,
502        ctx: &LayoutContext,
503    ) -> teksilo_core::widget::LayoutResponse {
504        self.root_child_id
505            .and_then(|id| ctx.child_size(id, proposal))
506            .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
507            .into()
508    }
509
510    fn place_children(
511        &self,
512        bounds: Rect,
513        _proposal: SizeProposal,
514        children: &mut [WidgetPlacement],
515        _ctx: &LayoutContext,
516    ) {
517        for child in children.iter_mut() {
518            child.origin = bounds.origin();
519            child.size = bounds.size();
520        }
521    }
522
523    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
524        // The outer Snackbar widget is just a layout shell around the
525        // focusable trigger (Button or OverlayTrigger). Hiding it from
526        // the platform a11y tree prevents a dead GenericContainer node
527        // from sitting between the trigger and its ancestors.
528        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
529        builder.set_hidden();
530    }
531
532    fn children(&self) -> Vec<WidgetId> {
533        self.root_child_id.into_iter().collect()
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use teksilo_canvas::Size;
541    use teksilo_core::widget_tree::WidgetTree;
542    use teksilo_i18n::lit;
543
544    #[derive(Debug)]
545    struct FixedLeaf(f32, f32);
546
547    impl Widget for FixedLeaf {
548        fn layout_response(
549            &self,
550            _proposal: SizeProposal,
551            _ctx: &LayoutContext,
552        ) -> teksilo_core::widget::LayoutResponse {
553            Size::new(self.0, self.1).into()
554        }
555    }
556
557    #[test]
558    fn access_click_opens_bottom_center_snackbar() {
559        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
560        tree.add(Snackbar::new(lit!("Show snackbar")).content(FixedLeaf(220.0, 40.0)));
561        tree.layout(SizeProposal::exact(800.0, 600.0));
562
563        let trigger = tree.find_by_label("Show snackbar").unwrap();
564        tree.dispatch_event(WidgetEvent::AccessAction {
565            action: teksilo_core::accesskit::Action::Click,
566            target: Some(trigger),
567            target_node: teksilo_core::accessibility::root_node_id(),
568            data: None,
569        });
570        tree.layout(SizeProposal::exact(800.0, 600.0));
571
572        assert_eq!(tree.active_overlays().len(), 1);
573        let content_id = tree.overlay_manager().active_content_ids()[0];
574        let bounds = tree.bounds(content_id);
575        let expected_x = (800.0 - bounds.width) / 2.0;
576        assert!((bounds.x - expected_x).abs() < 1.0);
577        assert!((bounds.y + bounds.height - (600.0 - 24.0)).abs() < 1.0);
578    }
579
580    #[test]
581    fn default_button_keyboard_activation_opens_snackbar() {
582        // The default-Button branch delegates all activation to
583        // `Button::on_activate_fn`, so a matched KeyDown + KeyUp pair on
584        // the focused trigger must present the snackbar — and inherits
585        // Button's lone-KeyUp guard for free.
586        use teksilo_core::event::Modifiers;
587        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
588        tree.add(Snackbar::new(lit!("Show snackbar")).content(FixedLeaf(220.0, 40.0)));
589        tree.layout(SizeProposal::exact(800.0, 600.0));
590
591        let trigger = tree.find_by_label("Show snackbar").unwrap();
592        tree.focus(trigger);
593
594        // A lone KeyUp (no matching KeyDown) must not activate.
595        tree.dispatch_event(WidgetEvent::KeyUp {
596            key: Key::Enter,
597            modifiers: Modifiers::NONE,
598        });
599        tree.layout(SizeProposal::exact(800.0, 600.0));
600        assert!(tree.active_overlays().is_empty());
601
602        // A matched KeyDown + KeyUp pair presents the snackbar.
603        tree.dispatch_event(WidgetEvent::KeyDown {
604            key: Key::Enter,
605            modifiers: Modifiers::NONE,
606            text: None,
607        });
608        tree.dispatch_event(WidgetEvent::KeyUp {
609            key: Key::Enter,
610            modifiers: Modifiers::NONE,
611        });
612        tree.layout(SizeProposal::exact(800.0, 600.0));
613        assert_eq!(tree.active_overlays().len(), 1);
614    }
615
616    #[test]
617    fn custom_trigger_opens_snackbar() {
618        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
619        tree.add(
620            Snackbar::new(lit!("Show snackbar"))
621                .content(FixedLeaf(180.0, 36.0))
622                .trigger(FixedLeaf(132.0, 36.0)),
623        );
624        tree.layout(SizeProposal::exact(640.0, 480.0));
625
626        // OverlayTrigger now routes handlers onto the trigger child;
627        // a pointer click on the wrapper hit-tests into the child where
628        // the handler lives.
629        let trigger = tree.find_by_label("Show snackbar").unwrap();
630        tree.click(trigger);
631
632        assert_eq!(tree.active_overlays().len(), 1);
633    }
634
635    #[test]
636    fn snackbar_auto_dismisses_after_duration() {
637        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
638        tree.add(
639            Snackbar::new(lit!("Show snackbar"))
640                .content(FixedLeaf(220.0, 40.0))
641                .auto_dismiss_after(Duration::from_millis(300)),
642        );
643        tree.layout(SizeProposal::exact(800.0, 600.0));
644
645        let trigger = tree.find_by_label("Show snackbar").unwrap();
646        tree.dispatch_event(WidgetEvent::AccessAction {
647            action: teksilo_core::accesskit::Action::Click,
648            target: Some(trigger),
649            target_node: teksilo_core::accessibility::root_node_id(),
650            data: None,
651        });
652        assert_eq!(tree.active_overlays().len(), 1);
653
654        tree.advance_time(Duration::from_millis(200));
655        assert_eq!(tree.active_overlays().len(), 1);
656
657        tree.advance_time(Duration::from_millis(150));
658        assert!(tree.active_overlays().is_empty());
659    }
660
661    #[test]
662    #[should_panic(expected = "Snackbar requires .content(...)")]
663    fn snackbar_without_content_panics_on_build() {
664        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
665        tree.add(Snackbar::new(lit!("Show snackbar")));
666        tree.layout(SizeProposal::exact(800.0, 600.0));
667    }
668}