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//!
40//! ## Touch and pen
41//!
42//! The trigger activates on the release, whether it is the default `Button` or a
43//! caller's widget, and the actions inside the surface are buttons and links with
44//! their own targets. Nothing here actuates on a press.
45
46use std::rc::Rc;
47use std::time::Duration;
48
49use teksilo_canvas::{Rect, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::build_context::BuildContext;
52use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
53use teksilo_core::signal::Prop;
54use teksilo_core::styles::{SharedSnackbarStyle, SnackbarStyleConfig};
55use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
56use teksilo_core::widget_id::WidgetId;
57
58use crate::button::{Button, ButtonVariant};
59use crate::overlay_trigger::OverlayTrigger;
60use teksilo_i18n::LocalizedString;
61
62const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(4);
63
64fn present_snackbar(
65    ctx: &mut teksilo_core::widget::EventContext,
66    anchor: WidgetId,
67    content_id: WidgetId,
68    shown: &teksilo_core::signal::Signal<bool>,
69    dismiss: DismissBehavior,
70    auto_dismiss_after: Option<Duration>,
71    fade_duration: Option<Duration>,
72) {
73    ctx.dismiss_all_except_hosts();
74    // Build the surface if this is the first time this snackbar is presented —
75    // `activate` alone would wake a node whose subtree does not exist yet.
76    shown.set(true);
77    ctx.materialize_now(content_id);
78    ctx.activate(content_id);
79    let mut request =
80        OverlayRequest::new(content_id, anchor, OverlayPlacement::BottomCenter, dismiss);
81    request.fade_duration = fade_duration;
82    if let Some(duration) = auto_dismiss_after {
83        ctx.show_overlay_for(request, duration);
84    } else {
85        ctx.show_overlay(request);
86    }
87}
88
89struct SnackbarSurface {
90    content_id: Option<WidgetId>,
91    pending_content: Option<PendingChild>,
92    /// Optional explicit SR announcement string. When set,
93    /// `accessibility()` uses it as the Alert's accessible name
94    /// so screen readers read out the caller-provided message
95    /// the moment the snackbar appears. Falls back to the
96    /// generic `a11y_snackbar_name` when unset.
97    announcement: Option<LocalizedString>,
98    /// Per-call override for the snackbar surface chrome.
99    style_override: Option<SharedSnackbarStyle>,
100    /// Build state — the `SnackbarStyle::make_body` root.
101    root_child_id: Option<WidgetId>,
102}
103
104impl SnackbarSurface {
105    fn new(content: PendingChild) -> Self {
106        Self {
107            content_id: None,
108            pending_content: Some(content),
109            announcement: None,
110            style_override: None,
111            root_child_id: None,
112        }
113    }
114
115    fn with_announcement(mut self, text: Option<LocalizedString>) -> Self {
116        self.announcement = text;
117        self
118    }
119
120    fn with_style(mut self, style: Option<SharedSnackbarStyle>) -> Self {
121        self.style_override = style;
122        self
123    }
124}
125
126impl std::fmt::Debug for SnackbarSurface {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("SnackbarSurface").finish()
129    }
130}
131
132impl Widget for SnackbarSurface {
133    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
134        if let Some(pending) = self.pending_content.take() {
135            self.content_id = Some(match pending {
136                PendingChild::Id(id) => id,
137                PendingChild::Deferred(w) => ctx.add_boxed(w),
138            });
139        }
140        // The surface chrome (dark `tooltip_bg` panel + border + padding
141        // inset) is owned by the active `SnackbarStyle`; this widget
142        // keeps its `Role::Alert` / `Live::Polite` accessibility node.
143        let content_id = self
144            .content_id
145            .expect("SnackbarSurface requires content — none was set");
146        let style: SharedSnackbarStyle = self
147            .style_override
148            .clone()
149            .or_else(|| ctx.theme().style_slots.snackbar.clone())
150            .unwrap_or_else(|| {
151                Rc::new(crate::styles::RecipeSnackbarStyle::for_tokens(
152                    &ctx.theme().input,
153                ))
154            });
155        let root_id = style.make_body(
156            &SnackbarStyleConfig {
157                content: content_id,
158            },
159            ctx,
160        );
161        self.root_child_id = Some(root_id);
162        // Swipe-to-dismiss. A snackbar's only user-driven exits are Escape
163        // (which needs focus, and the surface takes none) and a press outside —
164        // which under a finger means aiming at whatever is behind it. A
165        // horizontal swipe across the surface itself needs no aim and no
166        // keyboard, and it is what a touch user reaches for.
167        //
168        // Coarse pointers only. The handler's *presence* is enough to widen
169        // this node's accepted buttons to all of them
170        // (`ArenaRecognizers::accepted_buttons` short-circuits on a swipe), but
171        // the surface reads no press state, and gating the action here keeps a
172        // fast mouse drag across the panel from dismissing it.
173        //
174        // There is deliberately **no** pause-while-pointed-at: the snackbar has
175        // never had one for any input kind, so there is nothing here for touch
176        // to reach parity with. See `engineering/docs/hover-affordance-census.md`.
177        //
178        // Dismissed by *chain*, not by content id: the overlay's content is the
179        // deferred host `Snackbar::build` registered, and this widget is the
180        // child that host materialised — so this node's own id is not the
181        // overlay's, and asking by it would find nothing.
182        ctx.apply_self_handlers(teksilo_core::widget_builder::HandlerSet::new().on_swipe(
183            move |direction, _velocity, ctx: &mut teksilo_core::widget::EventContext| {
184                use teksilo_core::gesture::SwipeDirection;
185                if ctx.pointer_kind().is_coarse()
186                    && matches!(direction, SwipeDirection::Left | SwipeDirection::Right)
187                {
188                    ctx.dismiss_self_overlay_chain();
189                }
190            },
191        ));
192        vec![root_id]
193    }
194
195    fn layout_response(
196        &self,
197        proposal: SizeProposal,
198        ctx: &LayoutContext,
199    ) -> teksilo_core::widget::LayoutResponse {
200        self.root_child_id
201            .and_then(|id| ctx.child_size(id, proposal))
202            .unwrap_or_else(|| proposal.resolve(220.0, 44.0))
203            .into()
204    }
205
206    fn place_children(
207        &self,
208        bounds: Rect,
209        _proposal: SizeProposal,
210        children: &mut [WidgetPlacement],
211        _ctx: &LayoutContext,
212    ) {
213        for child in children.iter_mut() {
214            child.origin = bounds.origin();
215            child.size = bounds.size();
216        }
217    }
218
219    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
220        // Role::Alert + Live::Polite mirrors the ARIA pattern for
221        // transient notifications: screen readers announce the
222        // contents when the snackbar appears, without interrupting
223        // the user's current action. The accessible name is the
224        // caller-supplied announcement when present, otherwise
225        // the generic fallback. Child widgets still contribute
226        // their own nodes for full context.
227        builder.set_role(teksilo_core::accesskit::Role::Alert);
228        builder.set_live(teksilo_core::accesskit::Live::Polite);
229        let name = self
230            .announcement
231            .as_ref()
232            .map(|a| a.resolve_now())
233            .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_snackbar_name()).resolve_now());
234        builder.set_name(name);
235    }
236
237    fn children(&self) -> Vec<WidgetId> {
238        self.root_child_id.into_iter().collect()
239    }
240}
241
242/// A button-triggered transient notification surface.
243///
244/// Call `.content(...)` to supply the notification body, then add the
245/// widget to the tree. The trigger label is shown as a `Button` (or a
246/// custom widget via `.trigger(...)`); activating it presents the
247/// content surface at the bottom center of the window.
248pub struct Snackbar {
249    label: LocalizedString,
250    variant: ButtonVariant,
251    /// Enabled state (static or reactive). Wired into the arena on the
252    /// trigger node -- the default `Button` and, on the `.trigger(...)`
253    /// path, the `OverlayTrigger` -- so a disabled trigger greys out,
254    /// reports `disabled` to AT, and has its dispatch gated. The snapshot
255    /// read in `build()` is a redundant early-out kept in the custom-trigger
256    /// closures.
257    enabled: Prop<bool>,
258    dismiss: DismissBehavior,
259    auto_dismiss_after: Option<Duration>,
260    pending_content: Option<PendingChild>,
261    pending_trigger: Option<PendingChild>,
262    /// Optional explicit announcement string threaded through to
263    /// the `SnackbarSurface`'s a11y node. When set, screen readers
264    /// read this as the Alert's name when the snackbar appears.
265    announcement: Option<LocalizedString>,
266    /// Per-call override for the snackbar surface chrome.
267    style_override: Option<SharedSnackbarStyle>,
268    root_child_id: Option<WidgetId>,
269}
270
271impl Snackbar {
272    /// Create a snackbar whose default trigger button shows `label`.
273    pub fn new(label: impl Into<LocalizedString>) -> Self {
274        let ls: LocalizedString = label.into();
275        Self {
276            label: ls,
277            variant: ButtonVariant::Plain,
278            enabled: Prop::Static(true),
279            dismiss: DismissBehavior::ClickOutside,
280            auto_dismiss_after: Some(DEFAULT_AUTO_DISMISS),
281            pending_content: None,
282            pending_trigger: None,
283            announcement: None,
284            style_override: None,
285            root_child_id: None,
286        }
287    }
288
289    /// Per-call style override for the snackbar surface chrome.
290    /// Replaces the theme-wide default `SnackbarStyle` for just this
291    /// instance.
292    pub fn style(mut self, style: impl teksilo_core::styles::SnackbarStyle) -> Self {
293        self.style_override = Some(Rc::new(style));
294        self
295    }
296
297    /// The snackbar body — the message (and optional inline action)
298    /// shown on the floating surface.
299    ///
300    /// The default surface is the high-contrast (dark) `tooltip_bg`,
301    /// the same one tooltips use, and it stays dark in light theme.
302    /// So any `TextWidget` you pass here must set
303    /// `.color(TextRole::TooltipText)` (and actions can use
304    /// `TooltipText` / `TooltipShortcut`) — the default `TextRole::Primary`
305    /// is dark and renders nearly invisible on the dark surface in light
306    /// theme. If you install a light-surface `SnackbarStyle`, color the
307    /// content to match that instead.
308    pub fn content(mut self, content: impl teksilo_core::IntoTeksiChild) -> Self {
309        self.pending_content = Some(teksilo_core::IntoTeksiChild::into_pending(content));
310        self
311    }
312
313    /// Override the default trigger [`ButtonVariant`] (default: `Plain`).
314    pub fn variant(mut self, variant: ButtonVariant) -> Self {
315        self.variant = variant;
316        self
317    }
318
319    /// Set the enabled state of the trigger, statically or reactively.
320    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
321        self.enabled = enabled.into();
322        self
323    }
324
325    /// Override the overlay dismiss behavior (default: `ClickOutside`).
326    pub fn dismiss_behavior(mut self, dismiss: DismissBehavior) -> Self {
327        self.dismiss = dismiss;
328        self
329    }
330
331    /// Set the auto-dismiss timeout. The overlay is removed after this
332    /// duration without user interaction (default: 4 s).
333    pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
334        self.auto_dismiss_after = Some(duration);
335        self
336    }
337
338    /// Keep the snackbar visible until explicitly dismissed; disables
339    /// the auto-dismiss timeout.
340    pub fn persistent(mut self) -> Self {
341        self.auto_dismiss_after = None;
342        self
343    }
344
345    /// Replace the default `Button` trigger with a custom widget. The
346    /// widget is wired for tap, keyboard (Enter/Space), and AT Click
347    /// activation automatically.
348    pub fn trigger(mut self, trigger: impl teksilo_core::IntoTeksiChild) -> Self {
349        self.pending_trigger = Some(teksilo_core::IntoTeksiChild::into_pending(trigger));
350        self
351    }
352
353    /// Screen-reader announcement string — used as the Alert's
354    /// accessible name when the snackbar appears. Without this
355    /// the surface falls back to the generic `a11y_snackbar_name`
356    /// i18n string, which says "notification" but can't describe
357    /// the specific message. Set this whenever the snackbar
358    /// conveys information the user needs to hear (errors,
359    /// confirmations, status changes).
360    pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self {
361        let ls: LocalizedString = text.into();
362        self.announcement = Some(ls);
363        self
364    }
365}
366
367impl std::fmt::Debug for Snackbar {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        f.debug_struct("Snackbar")
370            .field("label", &self.label)
371            .field("style", &self.variant)
372            .field("enabled", &self.enabled.get())
373            .finish()
374    }
375}
376
377impl Widget for Snackbar {
378    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
379        let self_id = ctx.self_id();
380        let label = self.label.clone();
381        // Redundant early-out for the custom-trigger closures below; the
382        // arena.s `enabled_when` (wired on the OverlayTrigger) already gates
383        // dispatch, so this snapshot is belt-and-suspenders.
384        let enabled = self.enabled.get();
385        let dismiss = self.dismiss.clone();
386        let auto_dismiss_after = self.auto_dismiss_after;
387        let style = self.variant;
388        // Captured at build time so the present-snackbar handlers
389        // don't need a theme lookup at fire-time. `duration_normal`
390        // matches the snackbar's typical "notification slide"
391        // recommendation in MotionTokens.
392        let fade_duration = if ctx.prefers_reduced_motion() {
393            None
394        } else {
395            Some(ctx.theme().motion.duration_normal)
396        };
397        // The surface is built the first time the snackbar is presented, not on
398        // every rebuild of the trigger that presents it. See
399        // `teksilo_core::deferred_subtree::DeferredSubtree`.
400        let shown = ctx.signal(false);
401        let content_id = ctx.add_detached_deferred(
402            shown.clone(),
403            SnackbarSurface::new(
404                self.pending_content
405                    .take()
406                    .expect("Snackbar requires .content(...) — no content was set"),
407            )
408            .with_announcement(self.announcement.clone())
409            .with_style(self.style_override.clone()),
410        );
411        ctx.set_dormant(content_id);
412        // The surface is shown through an overlay, so it stays out of the
413        // child walk — but `add_detached` above still records who owns it, so
414        // it is reaped with this Snackbar rather than stranded.
415
416        let root_id = if let Some(trigger) = self.pending_trigger.take() {
417            // A custom trigger is an arbitrary widget with no built-in
418            // activation. `OverlayTrigger` wires this one opener to the
419            // pointer, to Enter/Space and to the AT `Click`, on the nodes each
420            // belongs on. (The default-Button branch below delegates all three
421            // to `Button::on_activate_fn`.)
422            let open = {
423                let shown = shown.clone();
424                move |ctx: &mut teksilo_core::widget::EventContext| {
425                    if !enabled {
426                        return;
427                    }
428                    present_snackbar(
429                        ctx,
430                        self_id,
431                        content_id,
432                        &shown,
433                        dismiss.clone(),
434                        auto_dismiss_after,
435                        fade_duration,
436                    );
437                }
438            };
439            let overlay_trigger = OverlayTrigger::from_pending(trigger)
440                .on_activate(open)
441                .activate_on_key_up()
442                .enabled(self.enabled.clone())
443                .name(label);
444            ctx.add(overlay_trigger)
445        } else {
446            // `Button::on_activate_fn` already fires on pointer tap,
447            // Space/Enter (with the matched-KeyDown guard), and AccessKit
448            // Click — so one handler covers all three activation paths.
449            ctx.add(
450                Button::new(label)
451                    .variant(style)
452                    .enabled(self.enabled.clone())
453                    .on_activate_fn(move |ctx| {
454                        present_snackbar(
455                            ctx,
456                            self_id,
457                            content_id,
458                            &shown,
459                            dismiss.clone(),
460                            auto_dismiss_after,
461                            fade_duration,
462                        );
463                    }),
464            )
465        };
466
467        self.root_child_id = Some(root_id);
468        vec![root_id]
469    }
470
471    fn layout_response(
472        &self,
473        proposal: SizeProposal,
474        ctx: &LayoutContext,
475    ) -> teksilo_core::widget::LayoutResponse {
476        self.root_child_id
477            .and_then(|id| ctx.child_size(id, proposal))
478            .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
479            .into()
480    }
481
482    fn place_children(
483        &self,
484        bounds: Rect,
485        _proposal: SizeProposal,
486        children: &mut [WidgetPlacement],
487        _ctx: &LayoutContext,
488    ) {
489        for child in children.iter_mut() {
490            child.origin = bounds.origin();
491            child.size = bounds.size();
492        }
493    }
494
495    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
496        // The outer Snackbar widget is just a layout shell around the
497        // focusable trigger (Button or OverlayTrigger). A bare
498        // `GenericContainer` is pruned by the walker and dropped by every
499        // adapter with the trigger promoted, so no dead node sits between
500        // the trigger and its ancestors. Never `set_hidden()`, which an
501        // adapter reads as hiding the trigger with it.
502        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
503    }
504
505    fn children(&self) -> Vec<WidgetId> {
506        self.root_child_id.into_iter().collect()
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use teksilo_canvas::Size;
514    use teksilo_core::event::{Key, WidgetEvent};
515    use teksilo_core::widget_tree::WidgetTree;
516    use teksilo_i18n::lit;
517
518    #[derive(Debug)]
519    struct FixedLeaf(f32, f32);
520
521    impl Widget for FixedLeaf {
522        fn layout_response(
523            &self,
524            _proposal: SizeProposal,
525            _ctx: &LayoutContext,
526        ) -> teksilo_core::widget::LayoutResponse {
527            Size::new(self.0, self.1).into()
528        }
529    }
530
531    #[test]
532    fn access_click_opens_bottom_center_snackbar() {
533        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
534        tree.add(Snackbar::new(lit!("Show snackbar")).content(FixedLeaf(220.0, 40.0)));
535        tree.layout(SizeProposal::exact(800.0, 600.0));
536
537        let trigger = tree.find_by_label("Show snackbar").unwrap();
538        tree.dispatch_event(WidgetEvent::AccessAction {
539            action: teksilo_core::accesskit::Action::Click,
540            target: Some(trigger),
541            target_node: teksilo_core::accessibility::root_node_id(),
542            data: None,
543        });
544        tree.layout(SizeProposal::exact(800.0, 600.0));
545
546        assert_eq!(tree.active_overlays().len(), 1);
547        let content_id = tree.overlay_manager().active_content_ids()[0];
548        let bounds = tree.bounds(content_id);
549        let expected_x = (800.0 - bounds.width) / 2.0;
550        assert!((bounds.x - expected_x).abs() < 1.0);
551        assert!((bounds.y + bounds.height - (600.0 - 24.0)).abs() < 1.0);
552    }
553
554    #[test]
555    fn default_button_keyboard_activation_opens_snackbar() {
556        // The default-Button branch delegates all activation to
557        // `Button::on_activate_fn`, so a matched KeyDown + KeyUp pair on
558        // the focused trigger must present the snackbar — and inherits
559        // Button's lone-KeyUp guard for free.
560        use teksilo_core::event::Modifiers;
561        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
562        tree.add(Snackbar::new(lit!("Show snackbar")).content(FixedLeaf(220.0, 40.0)));
563        tree.layout(SizeProposal::exact(800.0, 600.0));
564
565        let trigger = tree.find_by_label("Show snackbar").unwrap();
566        tree.focus(trigger);
567
568        // A lone KeyUp (no matching KeyDown) must not activate.
569        tree.dispatch_event(WidgetEvent::KeyUp {
570            key: Key::Enter,
571            modifiers: Modifiers::NONE,
572        });
573        tree.layout(SizeProposal::exact(800.0, 600.0));
574        assert!(tree.active_overlays().is_empty());
575
576        // A matched KeyDown + KeyUp pair presents the snackbar.
577        tree.dispatch_event(WidgetEvent::KeyDown {
578            key: Key::Enter,
579            modifiers: Modifiers::NONE,
580            text: None,
581        });
582        tree.dispatch_event(WidgetEvent::KeyUp {
583            key: Key::Enter,
584            modifiers: Modifiers::NONE,
585        });
586        tree.layout(SizeProposal::exact(800.0, 600.0));
587        assert_eq!(tree.active_overlays().len(), 1);
588    }
589
590    #[test]
591    fn custom_trigger_opens_snackbar() {
592        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
593        tree.add(
594            Snackbar::new(lit!("Show snackbar"))
595                .content(FixedLeaf(180.0, 36.0))
596                .trigger(FixedLeaf(132.0, 36.0)),
597        );
598        tree.layout(SizeProposal::exact(640.0, 480.0));
599
600        // OverlayTrigger now routes handlers onto the trigger child;
601        // a pointer click on the wrapper hit-tests into the child where
602        // the handler lives.
603        let trigger = tree.find_by_label("Show snackbar").unwrap();
604        tree.click(trigger);
605
606        assert_eq!(tree.active_overlays().len(), 1);
607    }
608
609    #[test]
610    fn snackbar_auto_dismisses_after_duration() {
611        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
612        tree.add(
613            Snackbar::new(lit!("Show snackbar"))
614                .content(FixedLeaf(220.0, 40.0))
615                .auto_dismiss_after(Duration::from_millis(300)),
616        );
617        tree.layout(SizeProposal::exact(800.0, 600.0));
618
619        let trigger = tree.find_by_label("Show snackbar").unwrap();
620        tree.dispatch_event(WidgetEvent::AccessAction {
621            action: teksilo_core::accesskit::Action::Click,
622            target: Some(trigger),
623            target_node: teksilo_core::accessibility::root_node_id(),
624            data: None,
625        });
626        assert_eq!(tree.active_overlays().len(), 1);
627
628        tree.advance_time(Duration::from_millis(200));
629        assert_eq!(tree.active_overlays().len(), 1);
630
631        tree.advance_time(Duration::from_millis(150));
632        assert!(tree.active_overlays().is_empty());
633    }
634
635    /// A snackbar that times out leaves focus where the reader has taken it.
636    ///
637    /// The snackbar's overlay records the focus it opened over (its trigger)
638    /// as its focus restore, and its timer running out handed focus back to
639    /// that trigger even after the reader had moved on: a reader who Tabbed
640    /// away was sent back to the button when the snackbar went.
641    #[test]
642    fn a_timed_out_snackbar_leaves_focus_where_the_reader_moved_it() {
643        use crate::button::Button;
644        use crate::common::heard_test::{Heard, Listener};
645        use crate::primitives::VStack;
646        use teksilo_core::event::Modifiers;
647
648        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
649        tree.add(
650            VStack::new()
651                .child(
652                    Snackbar::new(lit!("Show snackbar"))
653                        .content(FixedLeaf(220.0, 40.0))
654                        .auto_dismiss_after(Duration::from_millis(300)),
655                )
656                .child(Button::new(lit!("Elsewhere"))),
657        );
658        tree.layout(SizeProposal::exact(800.0, 600.0));
659
660        let trigger = tree.find_by_label("Show snackbar").unwrap();
661        tree.focus(trigger);
662        tree.press_key(Key::Enter, Modifiers::NONE);
663        assert_eq!(tree.active_overlays().len(), 1, "the snackbar is up");
664        let mut reader = Listener::attach(&mut tree);
665
666        tree.press_key(Key::Tab, Modifiers::NONE);
667        assert_eq!(
668            reader.heard(&mut tree),
669            vec![Heard::Focus("Elsewhere".into())],
670            "Tab moves on"
671        );
672
673        tree.advance_time(Duration::from_millis(400));
674        assert!(
675            tree.active_overlays().is_empty(),
676            "the snackbar has timed out"
677        );
678        assert_eq!(
679            reader.heard(&mut tree),
680            vec![],
681            "the snackbar timing out must not move focus back to its trigger"
682        );
683    }
684
685    /// A snackbar that times out while focus is inside it hands focus back to
686    /// its trigger rather than leaving the reader nowhere. The test above
687    /// passes just as well if a timeout never restores focus at all; this one
688    /// does not.
689    #[test]
690    fn a_snackbar_timing_out_with_focus_inside_hands_it_back_to_its_trigger() {
691        use crate::button::Button;
692        use crate::common::heard_test::{Heard, Listener};
693        use teksilo_core::event::Modifiers;
694
695        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
696        tree.add(
697            Snackbar::new(lit!("Show snackbar"))
698                .content(Button::new(lit!("Undo")))
699                .auto_dismiss_after(Duration::from_millis(300)),
700        );
701        tree.layout(SizeProposal::exact(800.0, 600.0));
702
703        let trigger = tree.find_by_label("Show snackbar").unwrap();
704        tree.focus(trigger);
705        tree.press_key(Key::Enter, Modifiers::NONE);
706        assert_eq!(tree.active_overlays().len(), 1, "the snackbar is up");
707        let undo = tree.find_by_label("Undo").expect("the snackbar's action");
708        tree.focus(undo);
709        let mut reader = Listener::attach(&mut tree);
710
711        tree.advance_time(Duration::from_millis(400));
712        assert!(
713            tree.active_overlays().is_empty(),
714            "the snackbar has timed out"
715        );
716        assert_eq!(
717            reader.heard(&mut tree),
718            vec![Heard::Focus("Show snackbar".into())],
719            "the snackbar timing out hands focus back to its trigger"
720        );
721        assert_eq!(tree.focused(), Some(trigger));
722    }
723
724    #[test]
725    #[should_panic(expected = "Snackbar requires .content(...)")]
726    fn snackbar_without_content_panics_on_build() {
727        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
728        tree.add(Snackbar::new(lit!("Show snackbar")));
729        tree.layout(SizeProposal::exact(800.0, 600.0));
730    }
731}