1use 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 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 announcement: Option<LocalizedString>,
98 style_override: Option<SharedSnackbarStyle>,
100 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 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 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 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
242pub struct Snackbar {
249 label: LocalizedString,
250 variant: ButtonVariant,
251 enabled: Prop<bool>,
258 dismiss: DismissBehavior,
259 auto_dismiss_after: Option<Duration>,
260 pending_content: Option<PendingChild>,
261 pending_trigger: Option<PendingChild>,
262 announcement: Option<LocalizedString>,
266 style_override: Option<SharedSnackbarStyle>,
268 root_child_id: Option<WidgetId>,
269}
270
271impl Snackbar {
272 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 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 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 pub fn variant(mut self, variant: ButtonVariant) -> Self {
315 self.variant = variant;
316 self
317 }
318
319 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
321 self.enabled = enabled.into();
322 self
323 }
324
325 pub fn dismiss_behavior(mut self, dismiss: DismissBehavior) -> Self {
327 self.dismiss = dismiss;
328 self
329 }
330
331 pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
334 self.auto_dismiss_after = Some(duration);
335 self
336 }
337
338 pub fn persistent(mut self) -> Self {
341 self.auto_dismiss_after = None;
342 self
343 }
344
345 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 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 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 let fade_duration = if ctx.prefers_reduced_motion() {
393 None
394 } else {
395 Some(ctx.theme().motion.duration_normal)
396 };
397 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 let root_id = if let Some(trigger) = self.pending_trigger.take() {
417 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 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 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 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 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 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 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 #[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 #[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}