1use 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 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 announcement: Option<LocalizedString>,
100 style_override: Option<SharedSnackbarStyle>,
102 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 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 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
210pub struct Snackbar {
217 label: LocalizedString,
218 variant: ButtonVariant,
219 enabled: Prop<bool>,
226 dismiss: DismissBehavior,
227 auto_dismiss_after: Option<Duration>,
228 pending_content: Option<PendingChild>,
229 pending_trigger: Option<PendingChild>,
230 announcement: Option<LocalizedString>,
234 style_override: Option<SharedSnackbarStyle>,
236 root_child_id: Option<WidgetId>,
237}
238
239impl Snackbar {
240 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 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 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 pub fn content_id(mut self, id: WidgetId) -> Self {
284 self.pending_content = Some(PendingChild::Id(id));
285 self
286 }
287
288 pub fn variant(mut self, variant: ButtonVariant) -> Self {
290 self.variant = variant;
291 self
292 }
293
294 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
296 self.enabled = enabled.into();
297 self
298 }
299
300 pub fn dismiss_behavior(mut self, dismiss: DismissBehavior) -> Self {
302 self.dismiss = dismiss;
303 self
304 }
305
306 pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
309 self.auto_dismiss_after = Some(duration);
310 self
311 }
312
313 pub fn persistent(mut self) -> Self {
316 self.auto_dismiss_after = None;
317 self
318 }
319
320 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 pub fn trigger_id(mut self, id: WidgetId) -> Self {
330 self.pending_trigger = Some(PendingChild::Id(id));
331 self
332 }
333
334 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 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 let fade_duration = if ctx.prefers_reduced_motion() {
374 None
375 } else {
376 Some(ctx.theme().motion.duration_normal)
377 };
378 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 let root_id = if let Some(trigger) = self.pending_trigger.take() {
398 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 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 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 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 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 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 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}