1use std::cell::Cell;
44use std::rc::Rc;
45
46use teksilo_canvas::{Rect, Size, SizeProposal};
47use teksilo_core::accessibility::AccessNodeBuilder;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::event::{EventResponse, Key, WidgetEvent};
50use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
51use teksilo_core::overlay::{OverlayDismissCallback, OverlayId};
52use teksilo_core::signal::{Prop, Signal};
53use teksilo_core::styles::{DialogStyleConfig, SharedDialogStyle};
54use teksilo_core::widget::{EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement};
55use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
56use teksilo_core::widget_id::WidgetId;
57use teksilo_tokens::{TextRole, TextStyleRole};
58
59use crate::button::{Button, ButtonVariant};
60use crate::overlay_trigger::OverlayTrigger;
61use crate::primitives::{Divider, TextWidget, VStack};
62use teksilo_i18n::LocalizedString;
63
64type DialogFactory = std::rc::Rc<dyn Fn() -> Box<dyn Widget>>;
65
66pub struct ModalContainer {
72 content_id: Option<WidgetId>,
73 pending_content: Option<Box<dyn Widget>>,
74 padding_override: Option<f32>,
75 min_width_override: Option<f32>,
76 title: Option<LocalizedString>,
89 style_override: Option<SharedDialogStyle>,
93 root_child_id: Option<WidgetId>,
95}
96
97impl ModalContainer {
98 pub fn new(content: impl Widget + 'static) -> Self {
100 Self::boxed(Box::new(content))
101 }
102
103 pub(crate) fn boxed(content: Box<dyn Widget>) -> Self {
104 Self {
105 content_id: None,
106 pending_content: Some(content),
107 padding_override: None,
108 min_width_override: None,
109 title: None,
110 style_override: None,
111 root_child_id: None,
112 }
113 }
114
115 pub fn padding(mut self, padding: f32) -> Self {
117 self.padding_override = Some(padding.max(0.0));
118 self
119 }
120
121 pub fn min_width(mut self, min_width: f32) -> Self {
123 self.min_width_override = Some(min_width.max(0.0));
124 self
125 }
126
127 pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
130 self.style_override = Some(Rc::new(style));
131 self
132 }
133
134 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
138 let ls: LocalizedString = title.into();
139 self.title = Some(ls);
140 self
141 }
142}
143
144impl std::fmt::Debug for ModalContainer {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("ModalContainer")
147 .field("padding_override", &self.padding_override)
148 .field("min_width_override", &self.min_width_override)
149 .finish()
150 }
151}
152
153impl Widget for ModalContainer {
154 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
155 if let Some(content) = self.pending_content.take() {
156 if self.title.is_none()
163 && let Some(hint) = content.accessible_title_hint()
164 {
165 self.title = Some(LocalizedString::literal(hint));
169 }
170 self.content_id = Some(ctx.add_boxed(content));
171 }
172
173 let content_id = self
177 .content_id
178 .expect("ModalContainer requires content — none was set");
179 let style: SharedDialogStyle = self
180 .style_override
181 .clone()
182 .or_else(|| ctx.theme().style_slots.dialog.clone())
183 .unwrap_or_else(|| Rc::new(crate::styles::RecipeDialogStyle::default()));
184 let cfg = DialogStyleConfig {
185 content: content_id,
186 has_scrim: true,
187 padding_override: self.padding_override,
188 min_width_override: self.min_width_override,
189 };
190 let root_id = style.make_panel(&cfg, ctx);
191 self.root_child_id = Some(root_id);
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(240.0, 120.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::Dialog);
221 let name = self
222 .title
223 .as_ref()
224 .map(|t| t.resolve_now())
225 .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_dialog_name()).resolve_now());
226 builder.set_name(name);
227 builder.set_modal();
231 }
232
233 fn children(&self) -> Vec<WidgetId> {
234 self.root_child_id.into_iter().collect()
235 }
236}
237
238pub struct ModalScrim {
254 style_override: Option<SharedDialogStyle>,
255 dismiss_target: Rc<Cell<Option<OverlayId>>>,
261 click_to_dismiss: bool,
267 root_child_id: Option<WidgetId>,
268}
269
270impl ModalScrim {
271 pub fn new() -> Self {
274 Self {
275 style_override: None,
276 dismiss_target: Rc::new(Cell::new(None)),
277 click_to_dismiss: false,
278 root_child_id: None,
279 }
280 }
281
282 pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
285 self.style_override = Some(Rc::new(style));
286 self
287 }
288
289 pub fn dismiss_target(mut self, target: Rc<Cell<Option<OverlayId>>>) -> Self {
293 self.dismiss_target = target;
294 self
295 }
296
297 pub fn click_to_dismiss(mut self, enabled: bool) -> Self {
300 self.click_to_dismiss = enabled;
301 self
302 }
303}
304
305impl Default for ModalScrim {
306 fn default() -> Self {
307 Self::new()
308 }
309}
310
311impl std::fmt::Debug for ModalScrim {
312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 f.debug_struct("ModalScrim")
314 .field("click_to_dismiss", &self.click_to_dismiss)
315 .finish()
316 }
317}
318
319impl Widget for ModalScrim {
320 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
321 let style: SharedDialogStyle = self
322 .style_override
323 .clone()
324 .or_else(|| ctx.theme().style_slots.dialog.clone())
325 .unwrap_or_else(|| Rc::new(crate::styles::RecipeDialogStyle::default()));
326 let chrome_id = style.make_scrim(ctx);
327
328 if self.click_to_dismiss {
329 let target = self.dismiss_target.clone();
330 let handlers = HandlerSet::new().on_tap(move |_event, ctx| {
331 if let Some(modal_id) = target.get() {
332 ctx.dismiss_overlay(modal_id);
333 }
334 });
335 ctx.apply_self_handlers(handlers);
336 }
337
338 self.root_child_id = Some(chrome_id);
339 vec![chrome_id]
340 }
341
342 fn layout_response(
343 &self,
344 proposal: SizeProposal,
345 ctx: &LayoutContext,
346 ) -> teksilo_core::widget::LayoutResponse {
347 self.root_child_id
353 .and_then(|id| ctx.child_size(id, proposal))
354 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
355 .into()
356 }
357
358 fn place_children(
359 &self,
360 bounds: Rect,
361 _proposal: SizeProposal,
362 children: &mut [WidgetPlacement],
363 _ctx: &LayoutContext,
364 ) {
365 for child in children.iter_mut() {
366 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
367 child.size = Size::new(bounds.width, bounds.height);
368 }
369 }
370
371 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
372 builder.set_hidden();
375 }
376
377 fn children(&self) -> Vec<WidgetId> {
378 self.root_child_id.into_iter().collect()
379 }
380}
381
382fn queue_dialog_request(
383 ctx: &mut EventContext,
384 factory: &DialogFactory,
385 presentation: ModalPresentation,
386 close_behavior: ModalCloseBehavior,
387 title: &str,
388 on_dismiss: Option<OverlayDismissCallback>,
389) {
390 let factory = factory.clone();
391 let mut request = ModalRequest::deferred(move |tree| {
392 let content = (factory.as_ref())();
393 tree.add(ModalContainer::boxed(content))
394 })
395 .presentation(presentation)
396 .close_behavior(close_behavior)
397 .title(title)
398 .size(460, 260);
399 if let Some(cb) = on_dismiss {
400 request = request.on_dismiss(cb);
401 }
402 ctx.present_modal(request);
403}
404
405pub struct DialogContent {
408 title: Option<LocalizedString>,
409 supporting_text: Option<LocalizedString>,
410 pending_body: Option<PendingChild>,
411 pending_footer: Option<PendingChild>,
412 root_child_id: Option<WidgetId>,
413}
414
415impl DialogContent {
416 pub fn new() -> Self {
418 Self {
419 title: None,
420 supporting_text: None,
421 pending_body: None,
422 pending_footer: None,
423 root_child_id: None,
424 }
425 }
426
427 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
430 self.title = Some(title.into());
431 self
432 }
433
434 pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self {
436 self.supporting_text = Some(text.into());
437 self
438 }
439
440 pub fn body(mut self, body: impl Widget + 'static) -> Self {
442 self.pending_body = Some(PendingChild::Deferred(Box::new(body)));
443 self
444 }
445
446 pub fn body_id(mut self, id: WidgetId) -> Self {
448 self.pending_body = Some(PendingChild::Id(id));
449 self
450 }
451
452 pub fn footer(mut self, footer: impl Widget + 'static) -> Self {
455 self.pending_footer = Some(PendingChild::Deferred(Box::new(footer)));
456 self
457 }
458
459 pub fn footer_id(mut self, id: WidgetId) -> Self {
461 self.pending_footer = Some(PendingChild::Id(id));
462 self
463 }
464}
465
466impl Default for DialogContent {
467 fn default() -> Self {
468 Self::new()
469 }
470}
471
472impl std::fmt::Debug for DialogContent {
473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474 f.debug_struct("DialogContent")
475 .field("title", &self.title)
476 .field("supporting_text", &self.supporting_text)
477 .finish()
478 }
479}
480
481impl Widget for DialogContent {
482 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
483 let mut stack = VStack::new().spacing(16.0);
484
485 if self.title.is_some() || self.supporting_text.is_some() {
486 let mut header = VStack::new().spacing(8.0);
487 if let Some(title) = self.title.clone() {
488 header = header.child(
489 TextWidget::new(title)
490 .style(TextStyleRole::BodyBold)
491 .color(TextRole::Primary)
492 .single_line(),
493 );
494 }
495 if let Some(text) = self.supporting_text.clone() {
496 header = header.child(
497 TextWidget::new(text)
498 .style(TextStyleRole::Body)
499 .color(TextRole::Secondary),
500 );
501 }
502 let header_id = ctx.add(header);
503 stack = stack.add_child(header_id);
504 }
505
506 if let Some(body) = self.pending_body.take() {
507 let body_id = match body {
508 PendingChild::Id(id) => id,
509 PendingChild::Deferred(w) => ctx.add_boxed(w),
510 };
511 stack = stack.add_child(body_id);
512 }
513
514 if let Some(footer) = self.pending_footer.take() {
515 let divider_id = ctx.add(Divider::new());
516 let footer_id = match footer {
517 PendingChild::Id(id) => id,
518 PendingChild::Deferred(w) => ctx.add_boxed(w),
519 };
520 stack = stack.add_child(divider_id).add_child(footer_id);
521 }
522
523 let root = ctx.add(stack);
524 self.root_child_id = Some(root);
525 vec![root]
526 }
527
528 fn layout_response(
529 &self,
530 proposal: SizeProposal,
531 ctx: &LayoutContext,
532 ) -> teksilo_core::widget::LayoutResponse {
533 self.root_child_id
534 .and_then(|id| ctx.child_size(id, proposal))
535 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
536 .into()
537 }
538
539 fn place_children(
540 &self,
541 bounds: Rect,
542 _proposal: SizeProposal,
543 children: &mut [WidgetPlacement],
544 _ctx: &LayoutContext,
545 ) {
546 for child in children.iter_mut() {
547 child.origin = bounds.origin();
548 child.size = bounds.size();
549 }
550 }
551
552 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
553 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
554 }
555
556 fn accessible_title_hint(&self) -> Option<String> {
561 self.title.as_ref().map(|t| t.resolve_now())
562 }
563
564 fn children(&self) -> Vec<WidgetId> {
565 self.root_child_id.into_iter().collect()
566 }
567}
568
569pub struct Dialog {
575 label: LocalizedString,
576 variant: ButtonVariant,
577 enabled: Prop<bool>,
580 presentation: ModalPresentation,
581 close_behavior: ModalCloseBehavior,
582 content_factory: Option<DialogFactory>,
583 pending_trigger: Option<PendingChild>,
584 root_child_id: Option<WidgetId>,
585}
586
587impl Dialog {
588 pub fn new(label: impl Into<LocalizedString>) -> Self {
590 Self {
591 label: label.into(),
592 variant: ButtonVariant::Filled,
593 enabled: Prop::Static(true),
594 presentation: ModalPresentation::Auto,
595 close_behavior: ModalCloseBehavior::EscapeOrClickOutside,
596 content_factory: None,
597 pending_trigger: None,
598 root_child_id: None,
599 }
600 }
601
602 pub fn content<W, F>(mut self, factory: F) -> Self
605 where
606 W: Widget + 'static,
607 F: Fn() -> W + 'static,
608 {
609 self.content_factory = Some(std::rc::Rc::new(move || {
610 Box::new(factory()) as Box<dyn Widget>
611 }));
612 self
613 }
614
615 pub fn variant(mut self, variant: ButtonVariant) -> Self {
618 self.variant = variant;
619 self
620 }
621
622 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
625 self.enabled = enabled.into();
626 self
627 }
628
629 pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
631 self.presentation = presentation;
632 self
633 }
634
635 pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
637 self.close_behavior = close_behavior;
638 self
639 }
640
641 pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self {
644 self.pending_trigger = Some(PendingChild::Deferred(Box::new(trigger)));
645 self
646 }
647
648 pub fn trigger_id(mut self, id: WidgetId) -> Self {
650 self.pending_trigger = Some(PendingChild::Id(id));
651 self
652 }
653}
654
655impl std::fmt::Debug for Dialog {
656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657 f.debug_struct("Dialog")
658 .field("label", &self.label)
659 .field("style", &self.variant)
660 .field("enabled", &self.enabled.get())
661 .finish()
662 }
663}
664
665impl Widget for Dialog {
666 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
667 let label = self.label.clone();
668 let enabled = self.enabled.as_signal();
674 let close_behavior = self.close_behavior;
675 let presentation = self.presentation;
676 let style = self.variant;
677 let content_factory = self
678 .content_factory
679 .clone()
680 .expect("Dialog requires .content(...) — no content factory was set");
681
682 let is_open: Signal<bool> = ctx.signal(false);
687 let dismiss_callback: OverlayDismissCallback = {
688 let is_open = is_open.clone();
689 std::rc::Rc::new(move || {
690 is_open.set(false);
691 })
692 };
693
694 let root_id = if let Some(trigger) = self.pending_trigger.take() {
695 let tap_open = is_open.clone();
696 let tap_dismiss = dismiss_callback.clone();
697 let key_open = is_open.clone();
698 let key_dismiss = dismiss_callback.clone();
699 let action_open = is_open.clone();
700 let action_dismiss = dismiss_callback.clone();
701 let handlers = teksilo_core::widget_builder::HandlerSet::new()
702 .focusable(true)
703 .cursor(teksilo_core::widget::CursorIcon::Pointer)
704 .on_tap({
705 let label = label.clone();
706 let content_factory = content_factory.clone();
707 let enabled = enabled.clone();
708 move |_pos, ctx| {
709 if !enabled.get() {
710 return;
711 }
712 tap_open.set(true);
713 queue_dialog_request(
714 ctx,
715 &content_factory,
716 presentation,
717 close_behavior,
718 &label.resolve_now(),
719 Some(tap_dismiss.clone()),
720 );
721 }
722 })
723 .on_key({
724 let label = label.clone();
725 let content_factory = content_factory.clone();
726 let enabled = enabled.clone();
727 move |event, ctx| match event {
728 WidgetEvent::KeyUp {
729 key: Key::Enter | Key::Space,
730 ..
731 } if enabled.get() => {
732 key_open.set(true);
733 queue_dialog_request(
734 ctx,
735 &content_factory,
736 presentation,
737 close_behavior,
738 &label.resolve_now(),
739 Some(key_dismiss.clone()),
740 );
741 EventResponse::Handled
742 }
743 _ => EventResponse::Ignored,
744 }
745 })
746 .on_access_action({
747 let label = label.clone();
748 let content_factory = content_factory.clone();
749 let enabled = enabled.clone();
750 move |action, ctx| {
751 if action == teksilo_core::accesskit::Action::Click && enabled.get() {
752 action_open.set(true);
753 queue_dialog_request(
754 ctx,
755 &content_factory,
756 presentation,
757 close_behavior,
758 &label.resolve_now(),
759 Some(action_dismiss.clone()),
760 );
761 EventResponse::Handled
762 } else {
763 EventResponse::Ignored
764 }
765 }
766 });
767 let overlay_trigger = match trigger {
768 PendingChild::Id(id) => OverlayTrigger::from_id(id, handlers),
769 PendingChild::Deferred(widget) => OverlayTrigger::new(widget, handlers),
770 }
771 .enabled(self.enabled.clone())
772 .name(label)
773 .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
774 .expanded_when(is_open.clone());
775 ctx.add(overlay_trigger)
776 } else {
777 let tap_open = is_open.clone();
778 let tap_dismiss = dismiss_callback.clone();
779 let key_open = is_open.clone();
780 let key_dismiss = dismiss_callback.clone();
781 let action_open = is_open.clone();
782 let action_dismiss = dismiss_callback.clone();
783 ctx.add(
784 Button::new(label)
785 .variant(style)
786 .enabled(enabled.clone())
787 .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
788 .expanded_when(is_open.clone())
789 .on_tap({
790 let label = self.label.clone();
791 let content_factory = content_factory.clone();
792 let enabled = enabled.clone();
793 move |_pos, ctx| {
794 if !enabled.get() {
795 return;
796 }
797 tap_open.set(true);
798 queue_dialog_request(
799 ctx,
800 &content_factory,
801 presentation,
802 close_behavior,
803 &label.resolve_now(),
804 Some(tap_dismiss.clone()),
805 );
806 }
807 })
808 .on_key({
809 let label = self.label.clone();
810 let content_factory = content_factory.clone();
811 let enabled = enabled.clone();
812 move |event, ctx| match event {
813 WidgetEvent::KeyUp {
814 key: Key::Enter | Key::Space,
815 ..
816 } if enabled.get() => {
817 key_open.set(true);
818 queue_dialog_request(
819 ctx,
820 &content_factory,
821 presentation,
822 close_behavior,
823 &label.resolve_now(),
824 Some(key_dismiss.clone()),
825 );
826 EventResponse::Handled
827 }
828 _ => EventResponse::Ignored,
829 }
830 })
831 .on_access_action({
832 let label = self.label.clone();
833 let content_factory = content_factory.clone();
834 let enabled = enabled.clone();
835 move |action, ctx| {
836 if action == teksilo_core::accesskit::Action::Click && enabled.get() {
837 action_open.set(true);
838 queue_dialog_request(
839 ctx,
840 &content_factory,
841 presentation,
842 close_behavior,
843 &label.resolve_now(),
844 Some(action_dismiss.clone()),
845 );
846 EventResponse::Handled
847 } else {
848 EventResponse::Ignored
849 }
850 }
851 }),
852 )
853 };
854
855 self.root_child_id = Some(root_id);
856 vec![root_id]
857 }
858
859 fn layout_response(
860 &self,
861 proposal: SizeProposal,
862 ctx: &LayoutContext,
863 ) -> teksilo_core::widget::LayoutResponse {
864 self.root_child_id
865 .and_then(|id| ctx.child_size(id, proposal))
866 .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
867 .into()
868 }
869
870 fn place_children(
871 &self,
872 bounds: Rect,
873 _proposal: SizeProposal,
874 children: &mut [WidgetPlacement],
875 _ctx: &LayoutContext,
876 ) {
877 for child in children.iter_mut() {
878 child.origin = bounds.origin();
879 child.size = bounds.size();
880 }
881 }
882
883 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
884 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
885 }
886
887 fn children(&self) -> Vec<WidgetId> {
888 self.root_child_id.into_iter().collect()
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use teksilo_canvas::Size;
896 use teksilo_core::widget_tree::WidgetTree;
897 use teksilo_core::{ModalContent, ModalPresentation};
898 use teksilo_i18n::lit;
899
900 #[derive(Debug)]
901 struct FixedLeaf(f32, f32);
902
903 impl Widget for FixedLeaf {
904 fn layout_response(
905 &self,
906 _proposal: SizeProposal,
907 _ctx: &LayoutContext,
908 ) -> teksilo_core::widget::LayoutResponse {
909 Size::new(self.0, self.1).into()
910 }
911 }
912
913 #[test]
914 fn access_click_opens_centered_dialog_overlay() {
915 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
916 tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
917 tree.layout(SizeProposal::exact(800.0, 600.0));
918
919 let trigger = tree.find_by_label("Open dialog").unwrap();
920 tree.dispatch_event(WidgetEvent::AccessAction {
921 action: teksilo_core::accesskit::Action::Click,
922 target: Some(trigger),
923 target_node: teksilo_core::accessibility::root_node_id(),
924 data: None,
925 });
926
927 let requests = tree.drain_pending_modal_requests();
928 assert_eq!(requests.len(), 1);
929 assert_eq!(requests[0].request.presentation, ModalPresentation::Auto);
930 assert_eq!(
931 requests[0].request.close_behavior,
932 ModalCloseBehavior::EscapeOrClickOutside,
933 );
934 assert!(matches!(
935 requests[0].request.content,
936 ModalContent::Deferred(_)
937 ));
938 }
939
940 #[test]
941 fn dialog_surface_exposes_dialog_role() {
942 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
943 tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
944 tree.layout(SizeProposal::exact(800.0, 600.0));
945
946 let trigger = tree.find_by_label("Open dialog").unwrap();
947 tree.dispatch_event(WidgetEvent::AccessAction {
948 action: teksilo_core::accesskit::Action::Click,
949 target: Some(trigger),
950 target_node: teksilo_core::accessibility::root_node_id(),
951 data: None,
952 });
953
954 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
955 let content_id = match request.content {
956 ModalContent::Deferred(builder) => builder(&mut tree),
957 ModalContent::ExistingWidget(_) => {
958 unreachable!("dialog now always uses deferred content")
959 }
960 };
961 tree.layout(SizeProposal::exact(800.0, 600.0));
962
963 let dialog = tree
964 .find_by_role(teksilo_core::accesskit::Role::Dialog)
965 .unwrap();
966 let info = tree.accessibility_node(dialog);
967 assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
968 assert!(tree.bounds(content_id).width > 0.0);
969 }
970
971 #[test]
972 fn modal_container_inherits_title_from_dialog_content() {
973 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
978 let container = tree.add(ModalContainer::new(
979 DialogContent::new()
980 .title(lit!("Delete file?"))
981 .body(FixedLeaf(100.0, 40.0)),
982 ));
983 tree.layout(SizeProposal::exact(600.0, 400.0));
984 let info = tree.accessibility_node(container);
985 assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
986 assert_eq!(info.name(), Some("Delete file?"));
987 }
988
989 #[test]
990 fn modal_container_explicit_title_wins_over_hint() {
991 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
994 let container = tree.add(
995 ModalContainer::new(
996 DialogContent::new()
997 .title(lit!("Inner title"))
998 .body(FixedLeaf(100.0, 40.0)),
999 )
1000 .title(lit!("Outer title")),
1001 );
1002 tree.layout(SizeProposal::exact(600.0, 400.0));
1003 let info = tree.accessibility_node(container);
1004 assert_eq!(info.name(), Some("Outer title"));
1005 }
1006
1007 #[derive(Debug)]
1014 struct HintingPanel {
1015 first: Rc<std::cell::Cell<Option<WidgetId>>>,
1016 hinted: Rc<std::cell::Cell<Option<WidgetId>>>,
1017 }
1018
1019 impl Widget for HintingPanel {
1020 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1021 let first = ctx.add(FixedLeaf(40.0, 20.0));
1022 let hinted = ctx.add(FixedLeaf(40.0, 20.0));
1023 self.first.set(Some(first));
1024 self.hinted.set(Some(hinted));
1025 vec![first, hinted]
1026 }
1027
1028 fn initial_focus_hint(&self) -> Option<WidgetId> {
1029 self.hinted.get()
1030 }
1031
1032 fn layout_response(
1033 &self,
1034 _proposal: SizeProposal,
1035 _ctx: &LayoutContext,
1036 ) -> teksilo_core::widget::LayoutResponse {
1037 Size::new(220.0, 120.0).into()
1038 }
1039 }
1040
1041 #[test]
1054 fn modal_container_lets_its_content_direct_initial_focus() {
1055 let first = Rc::new(std::cell::Cell::new(None));
1056 let hinted = Rc::new(std::cell::Cell::new(None));
1057
1058 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1059 let container = tree.add(ModalContainer::new(HintingPanel {
1060 first: first.clone(),
1061 hinted: hinted.clone(),
1062 }));
1063 tree.layout(SizeProposal::exact(600.0, 400.0));
1064
1065 let target = tree.widget_initial_focus_hint(container);
1066 assert_eq!(
1067 target,
1068 hinted.get(),
1069 "the content's hint must survive being wrapped in a ModalContainer"
1070 );
1071 assert_ne!(
1072 target,
1073 first.get(),
1074 "…and must not fall back to the first descendant, which is the \
1075 close button in a real dialog"
1076 );
1077 }
1078
1079 #[test]
1083 fn modal_container_without_a_hint_reports_none() {
1084 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1085 let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1086 tree.layout(SizeProposal::exact(600.0, 400.0));
1087
1088 assert_eq!(tree.widget_initial_focus_hint(container), None);
1089 }
1090
1091 #[test]
1092 fn modal_container_preserves_shell_sizing_defaults() {
1093 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1094 let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1095 tree.layout(SizeProposal {
1096 width: None,
1097 height: None,
1098 });
1099
1100 let bounds = tree.bounds(container);
1103 assert!((bounds.width - 280.0).abs() < 0.01);
1104 assert!((bounds.height - 168.0).abs() < 0.01);
1105 }
1106
1107 #[test]
1108 fn modal_container_custom_padding_changes_layout() {
1109 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1110 let container = tree.add(
1111 ModalContainer::new(FixedLeaf(220.0, 120.0))
1112 .padding(12.0)
1113 .min_width(200.0),
1114 );
1115 tree.layout(SizeProposal {
1116 width: None,
1117 height: None,
1118 });
1119
1120 let bounds = tree.bounds(container);
1121 assert!((bounds.width - 244.0).abs() < 0.01);
1122 assert!((bounds.height - 144.0).abs() < 0.01);
1123 }
1124
1125 #[test]
1126 fn custom_trigger_opens_dialog_overlay() {
1127 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1128 tree.add(
1129 Dialog::new(lit!("Open dialog"))
1130 .content(|| FixedLeaf(220.0, 120.0))
1131 .trigger(FixedLeaf(140.0, 40.0)),
1132 );
1133 tree.layout(SizeProposal::exact(800.0, 600.0));
1134
1135 let trigger = tree.find_by_label("Open dialog").unwrap();
1141 tree.click(trigger);
1142
1143 assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1144 }
1145
1146 #[test]
1147 fn dialog_content_helper_builds_dialog_sections() {
1148 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1149 tree.add(Dialog::new(lit!("Open dialog")).content(|| {
1150 DialogContent::new()
1151 .title(lit!("Review Changes"))
1152 .supporting_text(lit!("Confirm the staged updates before continuing."))
1153 .body(FixedLeaf(220.0, 120.0))
1154 .footer(Button::new(lit!("Close")))
1155 }));
1156 tree.layout(SizeProposal::exact(800.0, 600.0));
1157
1158 let trigger = tree.find_by_label("Open dialog").unwrap();
1159 tree.dispatch_event(WidgetEvent::AccessAction {
1160 action: teksilo_core::accesskit::Action::Click,
1161 target: Some(trigger),
1162 target_node: teksilo_core::accessibility::root_node_id(),
1163 data: None,
1164 });
1165
1166 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1167 match request.content {
1168 ModalContent::Deferred(builder) => {
1169 builder(&mut tree);
1170 }
1171 ModalContent::ExistingWidget(_) => {
1172 unreachable!("dialog now always uses deferred content")
1173 }
1174 }
1175 tree.layout(SizeProposal::exact(800.0, 600.0));
1176
1177 assert!(tree.find_by_label("Review Changes").is_some());
1178 assert!(tree.find_by_label("Close").is_some());
1179 }
1180
1181 #[test]
1182 fn dialog_presentation_can_be_overridden() {
1183 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184 tree.add(
1185 Dialog::new(lit!("Open dialog"))
1186 .content(|| FixedLeaf(220.0, 120.0))
1187 .presentation(ModalPresentation::InTree),
1188 );
1189 tree.layout(SizeProposal::exact(800.0, 600.0));
1190
1191 let trigger = tree.find_by_label("Open dialog").unwrap();
1192 tree.dispatch_event(WidgetEvent::AccessAction {
1193 action: teksilo_core::accesskit::Action::Click,
1194 target: Some(trigger),
1195 target_node: teksilo_core::accessibility::root_node_id(),
1196 data: None,
1197 });
1198
1199 let requests = tree.drain_pending_modal_requests();
1200 assert_eq!(requests.len(), 1);
1201 assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
1202 }
1203
1204 #[test]
1205 fn dialog_close_behavior_can_be_overridden() {
1206 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1207 tree.add(
1208 Dialog::new(lit!("Open dialog"))
1209 .content(|| FixedLeaf(220.0, 120.0))
1210 .close_behavior(ModalCloseBehavior::Manual),
1211 );
1212 tree.layout(SizeProposal::exact(800.0, 600.0));
1213
1214 let trigger = tree.find_by_label("Open dialog").unwrap();
1215 tree.dispatch_event(WidgetEvent::AccessAction {
1216 action: teksilo_core::accesskit::Action::Click,
1217 target: Some(trigger),
1218 target_node: teksilo_core::accessibility::root_node_id(),
1219 data: None,
1220 });
1221
1222 let requests = tree.drain_pending_modal_requests();
1223 assert_eq!(requests.len(), 1);
1224 assert_eq!(
1225 requests[0].request.close_behavior,
1226 ModalCloseBehavior::Manual
1227 );
1228 }
1229
1230 #[test]
1231 #[should_panic(expected = "Dialog requires .content(...)")]
1232 fn dialog_without_content_panics_on_build() {
1233 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1234 tree.add(Dialog::new(lit!("Open dialog")));
1235 tree.layout(SizeProposal::exact(800.0, 600.0));
1236 }
1237}