1use std::cell::Cell;
59use std::rc::Rc;
60
61use teksilo_canvas::{Rect, Size, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::event::{EventResponse, Key, WidgetEvent};
65use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
66use teksilo_core::overlay::{OverlayDismissCallback, OverlayId};
67use teksilo_core::signal::{Prop, Signal};
68use teksilo_core::styles::{DialogStyleConfig, SharedDialogStyle};
69use teksilo_core::widget::{EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement};
70use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
71use teksilo_core::widget_id::WidgetId;
72use teksilo_tokens::{TextRole, TextStyleRole};
73
74use crate::button::{Button, ButtonVariant};
75use crate::overlay_trigger::OverlayTrigger;
76use crate::primitives::{Divider, TextWidget, VStack};
77use teksilo_i18n::LocalizedString;
78
79type DialogFactory = std::rc::Rc<dyn Fn() -> Box<dyn Widget>>;
80
81pub struct ModalContainer {
87 content_id: Option<WidgetId>,
88 pending_content: Option<Box<dyn Widget>>,
89 padding_override: Option<f32>,
90 min_width_override: Option<f32>,
91 title: Option<LocalizedString>,
104 style_override: Option<SharedDialogStyle>,
108 root_child_id: Option<WidgetId>,
110 named_by_content: bool,
115 content_is_dialog: bool,
119}
120
121impl ModalContainer {
122 pub fn new(content: impl Widget + 'static) -> Self {
124 Self::boxed(Box::new(content))
125 }
126
127 pub(crate) fn boxed(content: Box<dyn Widget>) -> Self {
128 Self {
129 content_id: None,
130 pending_content: Some(content),
131 padding_override: None,
132 min_width_override: None,
133 title: None,
134 style_override: None,
135 root_child_id: None,
136 named_by_content: false,
137 content_is_dialog: false,
138 }
139 }
140
141 pub fn padding(mut self, padding: f32) -> Self {
143 self.padding_override = Some(padding.max(0.0));
144 self
145 }
146
147 pub fn min_width(mut self, min_width: f32) -> Self {
149 self.min_width_override = Some(min_width.max(0.0));
150 self
151 }
152
153 pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
156 self.style_override = Some(Rc::new(style));
157 self
158 }
159
160 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
167 let ls: LocalizedString = title.into();
168 self.title = Some(ls);
169 self
170 }
171}
172
173impl std::fmt::Debug for ModalContainer {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.debug_struct("ModalContainer")
176 .field("padding_override", &self.padding_override)
177 .field("min_width_override", &self.min_width_override)
178 .finish()
179 }
180}
181
182impl Widget for ModalContainer {
183 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
184 if let Some(content) = self.pending_content.take() {
185 let mut probe = AccessNodeBuilder::new();
189 content.accessibility(&mut probe);
190 self.content_is_dialog = matches!(
191 probe.role(),
192 teksilo_core::accesskit::Role::Dialog | teksilo_core::accesskit::Role::AlertDialog
193 );
194 if self.title.is_none()
201 && !self.content_is_dialog
202 && let Some(hint) = content.accessible_title_hint()
203 {
204 self.title = Some(LocalizedString::literal(hint));
208 }
209 let content_id = ctx.add_boxed(content);
210 self.content_id = Some(content_id);
211 if !self.content_is_dialog
218 && let Some(title_id) = ctx.accessible_title_node(content_id)
219 {
220 let self_id = ctx.self_id();
221 ctx.access_labelled_by(self_id, title_id);
222 self.named_by_content = true;
223 }
224 }
225
226 let content_id = self
230 .content_id
231 .expect("ModalContainer requires content — none was set");
232 let style: SharedDialogStyle = self
233 .style_override
234 .clone()
235 .or_else(|| ctx.theme().style_slots.dialog.clone())
236 .unwrap_or_else(|| {
237 Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
238 &ctx.theme().input,
239 ))
240 });
241 let cfg = DialogStyleConfig {
242 content: content_id,
243 has_scrim: true,
244 padding_override: self.padding_override,
245 min_width_override: self.min_width_override,
246 };
247 let root_id = style.make_panel(&cfg, ctx);
248 self.root_child_id = Some(root_id);
249 vec![root_id]
250 }
251
252 fn layout_response(
253 &self,
254 proposal: SizeProposal,
255 ctx: &LayoutContext,
256 ) -> teksilo_core::widget::LayoutResponse {
257 self.root_child_id
258 .and_then(|id| ctx.child_size(id, proposal))
259 .unwrap_or_else(|| proposal.resolve(240.0, 120.0))
260 .into()
261 }
262
263 fn place_children(
264 &self,
265 bounds: Rect,
266 _proposal: SizeProposal,
267 children: &mut [WidgetPlacement],
268 _ctx: &LayoutContext,
269 ) {
270 for child in children.iter_mut() {
271 child.origin = bounds.origin();
272 child.size = bounds.size();
273 }
274 }
275
276 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
277 if self.content_is_dialog {
278 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
290 return;
291 }
292 builder.set_role(teksilo_core::accesskit::Role::Dialog);
293 if !self.named_by_content {
297 let name = self
298 .title
299 .as_ref()
300 .map(|t| t.resolve_now())
301 .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_dialog_name()).resolve_now());
302 builder.set_name(name);
303 }
304 builder.set_modal();
308 }
309
310 fn children(&self) -> Vec<WidgetId> {
311 self.root_child_id.into_iter().collect()
312 }
313}
314
315pub struct ModalScrim {
331 style_override: Option<SharedDialogStyle>,
332 dismiss_target: Rc<Cell<Option<OverlayId>>>,
338 click_to_dismiss: bool,
344 root_child_id: Option<WidgetId>,
345}
346
347impl ModalScrim {
348 pub fn new() -> Self {
351 Self {
352 style_override: None,
353 dismiss_target: Rc::new(Cell::new(None)),
354 click_to_dismiss: false,
355 root_child_id: None,
356 }
357 }
358
359 pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
362 self.style_override = Some(Rc::new(style));
363 self
364 }
365
366 pub fn dismiss_target(mut self, target: Rc<Cell<Option<OverlayId>>>) -> Self {
370 self.dismiss_target = target;
371 self
372 }
373
374 pub fn click_to_dismiss(mut self, enabled: bool) -> Self {
377 self.click_to_dismiss = enabled;
378 self
379 }
380}
381
382impl Default for ModalScrim {
383 fn default() -> Self {
384 Self::new()
385 }
386}
387
388impl std::fmt::Debug for ModalScrim {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("ModalScrim")
391 .field("click_to_dismiss", &self.click_to_dismiss)
392 .finish()
393 }
394}
395
396impl Widget for ModalScrim {
397 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
398 let style: SharedDialogStyle = self
399 .style_override
400 .clone()
401 .or_else(|| ctx.theme().style_slots.dialog.clone())
402 .unwrap_or_else(|| {
403 Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
404 &ctx.theme().input,
405 ))
406 });
407 let chrome_id = style.make_scrim(ctx);
408
409 let mut handlers = HandlerSet::new().no_hit_slop();
415 if self.click_to_dismiss {
416 let target = self.dismiss_target.clone();
417 handlers = handlers.on_tap(move |_event, ctx| {
418 if let Some(modal_id) = target.get() {
419 ctx.dismiss_overlay(modal_id);
420 }
421 });
422 }
423 ctx.apply_self_handlers(handlers);
424
425 self.root_child_id = Some(chrome_id);
426 vec![chrome_id]
427 }
428
429 fn layout_response(
430 &self,
431 proposal: SizeProposal,
432 ctx: &LayoutContext,
433 ) -> teksilo_core::widget::LayoutResponse {
434 self.root_child_id
440 .and_then(|id| ctx.child_size(id, proposal))
441 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
442 .into()
443 }
444
445 fn place_children(
446 &self,
447 bounds: Rect,
448 _proposal: SizeProposal,
449 children: &mut [WidgetPlacement],
450 _ctx: &LayoutContext,
451 ) {
452 for child in children.iter_mut() {
453 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
454 child.size = Size::new(bounds.width, bounds.height);
455 }
456 }
457
458 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
459 builder.set_hidden();
462 }
463
464 fn children(&self) -> Vec<WidgetId> {
465 self.root_child_id.into_iter().collect()
466 }
467}
468
469fn queue_dialog_request(
470 ctx: &mut EventContext,
471 factory: &DialogFactory,
472 presentation: ModalPresentation,
473 close_behavior: ModalCloseBehavior,
474 title: &str,
475 on_dismiss: Option<OverlayDismissCallback>,
476) {
477 let factory = factory.clone();
478 let mut request = ModalRequest::deferred(move |tree| {
479 let content = (factory.as_ref())();
480 tree.add(ModalContainer::boxed(content))
481 })
482 .presentation(presentation)
483 .close_behavior(close_behavior)
484 .title(title)
485 .size(460, 260);
486 if let Some(cb) = on_dismiss {
487 request = request.on_dismiss(cb);
488 }
489 ctx.present_modal(request);
490}
491
492pub struct DialogContent {
495 title: Option<LocalizedString>,
496 supporting_text: Option<LocalizedString>,
497 pending_body: Option<PendingChild>,
498 pending_footer: Option<PendingChild>,
499 root_child_id: Option<WidgetId>,
500 title_node: Option<WidgetId>,
503}
504
505impl DialogContent {
506 pub fn new() -> Self {
508 Self {
509 title: None,
510 supporting_text: None,
511 pending_body: None,
512 pending_footer: None,
513 root_child_id: None,
514 title_node: None,
515 }
516 }
517
518 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
521 self.title = Some(title.into());
522 self
523 }
524
525 pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self {
527 self.supporting_text = Some(text.into());
528 self
529 }
530
531 pub fn body(mut self, body: impl teksilo_core::IntoTeksiChild) -> Self {
533 self.pending_body = Some(teksilo_core::IntoTeksiChild::into_pending(body));
534 self
535 }
536
537 pub fn footer(mut self, footer: impl teksilo_core::IntoTeksiChild) -> Self {
540 self.pending_footer = Some(teksilo_core::IntoTeksiChild::into_pending(footer));
541 self
542 }
543}
544
545impl Default for DialogContent {
546 fn default() -> Self {
547 Self::new()
548 }
549}
550
551impl std::fmt::Debug for DialogContent {
552 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553 f.debug_struct("DialogContent")
554 .field("title", &self.title)
555 .field("supporting_text", &self.supporting_text)
556 .finish()
557 }
558}
559
560impl Widget for DialogContent {
561 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
562 let mut stack = VStack::new().spacing(16.0);
563
564 if self.title.is_some() || self.supporting_text.is_some() {
565 let mut header = VStack::new().spacing(8.0);
566 if let Some(title) = self.title.clone() {
567 let title_id = ctx.add(
572 TextWidget::new(title)
573 .style(TextStyleRole::BodyBold)
574 .color(TextRole::Primary)
575 .single_line(),
576 );
577 self.title_node = Some(title_id);
578 header = header.child(title_id);
579 }
580 if let Some(text) = self.supporting_text.clone() {
581 header = header.child(
582 TextWidget::new(text)
583 .style(TextStyleRole::Body)
584 .color(TextRole::Secondary),
585 );
586 }
587 let header_id = ctx.add(header);
588 stack = stack.child(header_id);
589 }
590
591 if let Some(body) = self.pending_body.take() {
592 let body_id = match body {
593 PendingChild::Id(id) => id,
594 PendingChild::Deferred(w) => ctx.add_boxed(w),
595 };
596 stack = stack.child(body_id);
597 }
598
599 if let Some(footer) = self.pending_footer.take() {
600 let divider_id = ctx.add(Divider::new());
601 let footer_id = match footer {
602 PendingChild::Id(id) => id,
603 PendingChild::Deferred(w) => ctx.add_boxed(w),
604 };
605 stack = stack.child(divider_id).child(footer_id);
606 }
607
608 let root = ctx.add(stack);
609 self.root_child_id = Some(root);
610 vec![root]
611 }
612
613 fn layout_response(
614 &self,
615 proposal: SizeProposal,
616 ctx: &LayoutContext,
617 ) -> teksilo_core::widget::LayoutResponse {
618 self.root_child_id
619 .and_then(|id| ctx.child_size(id, proposal))
620 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
621 .into()
622 }
623
624 fn place_children(
625 &self,
626 bounds: Rect,
627 _proposal: SizeProposal,
628 children: &mut [WidgetPlacement],
629 _ctx: &LayoutContext,
630 ) {
631 for child in children.iter_mut() {
632 child.origin = bounds.origin();
633 child.size = bounds.size();
634 }
635 }
636
637 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
638 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
639 }
640
641 fn accessible_title_node(&self) -> Option<WidgetId> {
646 self.title_node
647 }
648
649 fn accessible_title_hint(&self) -> Option<String> {
650 self.title.as_ref().map(|t| t.resolve_now())
651 }
652
653 fn children(&self) -> Vec<WidgetId> {
654 self.root_child_id.into_iter().collect()
655 }
656}
657
658pub struct Dialog {
664 label: LocalizedString,
665 variant: ButtonVariant,
666 enabled: Prop<bool>,
669 presentation: ModalPresentation,
670 close_behavior: ModalCloseBehavior,
671 content_factory: Option<DialogFactory>,
672 pending_trigger: Option<PendingChild>,
673 root_child_id: Option<WidgetId>,
674}
675
676impl Dialog {
677 pub fn new(label: impl Into<LocalizedString>) -> Self {
679 Self {
680 label: label.into(),
681 variant: ButtonVariant::Filled,
682 enabled: Prop::Static(true),
683 presentation: ModalPresentation::Auto,
684 close_behavior: ModalCloseBehavior::EscapeOrClickOutside,
685 content_factory: None,
686 pending_trigger: None,
687 root_child_id: None,
688 }
689 }
690
691 pub fn content<W, F>(mut self, factory: F) -> Self
694 where
695 W: Widget + 'static,
696 F: Fn() -> W + 'static,
697 {
698 self.content_factory = Some(std::rc::Rc::new(move || {
699 Box::new(factory()) as Box<dyn Widget>
700 }));
701 self
702 }
703
704 pub fn variant(mut self, variant: ButtonVariant) -> Self {
707 self.variant = variant;
708 self
709 }
710
711 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
714 self.enabled = enabled.into();
715 self
716 }
717
718 pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
720 self.presentation = presentation;
721 self
722 }
723
724 pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
726 self.close_behavior = close_behavior;
727 self
728 }
729
730 pub fn trigger(mut self, trigger: impl teksilo_core::IntoTeksiChild) -> Self {
733 self.pending_trigger = Some(teksilo_core::IntoTeksiChild::into_pending(trigger));
734 self
735 }
736}
737
738impl std::fmt::Debug for Dialog {
739 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
740 f.debug_struct("Dialog")
741 .field("label", &self.label)
742 .field("style", &self.variant)
743 .field("enabled", &self.enabled.get())
744 .finish()
745 }
746}
747
748impl Widget for Dialog {
749 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
750 let label = self.label.clone();
751 let enabled = self.enabled.as_signal();
757 let close_behavior = self.close_behavior;
758 let presentation = self.presentation;
759 let style = self.variant;
760 let content_factory = self
761 .content_factory
762 .clone()
763 .expect("Dialog requires .content(...) — no content factory was set");
764
765 let is_open: Signal<bool> = ctx.signal(false);
770 let dismiss_callback: OverlayDismissCallback = {
771 let is_open = is_open.clone();
772 std::rc::Rc::new(move |_, _| {
773 is_open.set(false);
774 })
775 };
776
777 let root_id = if let Some(trigger) = self.pending_trigger.take() {
778 let open = {
782 let label = label.clone();
783 let content_factory = content_factory.clone();
784 let enabled = enabled.clone();
785 let is_open = is_open.clone();
786 let dismiss = dismiss_callback.clone();
787 move |ctx: &mut teksilo_core::widget::EventContext| {
788 if !enabled.get() {
789 return;
790 }
791 is_open.set(true);
792 queue_dialog_request(
793 ctx,
794 &content_factory,
795 presentation,
796 close_behavior,
797 &label.resolve_now(),
798 Some(dismiss.clone()),
799 );
800 }
801 };
802 let overlay_trigger = OverlayTrigger::from_pending(trigger)
803 .on_activate(open)
804 .activate_on_key_up()
805 .enabled(self.enabled.clone())
806 .name(label)
807 .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
808 .expanded_when(is_open.clone());
809 ctx.add(overlay_trigger)
810 } else {
811 let tap_open = is_open.clone();
812 let tap_dismiss = dismiss_callback.clone();
813 let key_open = is_open.clone();
814 let key_dismiss = dismiss_callback.clone();
815 let action_open = is_open.clone();
816 let action_dismiss = dismiss_callback.clone();
817 ctx.add(
818 Button::new(label)
819 .variant(style)
820 .enabled(enabled.clone())
821 .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
822 .expanded_when(is_open.clone())
823 .on_tap({
824 let label = self.label.clone();
825 let content_factory = content_factory.clone();
826 let enabled = enabled.clone();
827 move |_pos, ctx| {
828 if !enabled.get() {
829 return;
830 }
831 tap_open.set(true);
832 queue_dialog_request(
833 ctx,
834 &content_factory,
835 presentation,
836 close_behavior,
837 &label.resolve_now(),
838 Some(tap_dismiss.clone()),
839 );
840 }
841 })
842 .on_key({
843 let label = self.label.clone();
844 let content_factory = content_factory.clone();
845 let enabled = enabled.clone();
846 move |event, ctx| match event {
847 WidgetEvent::KeyUp {
848 key: Key::Enter | Key::Space,
849 ..
850 } if enabled.get() => {
851 key_open.set(true);
852 queue_dialog_request(
853 ctx,
854 &content_factory,
855 presentation,
856 close_behavior,
857 &label.resolve_now(),
858 Some(key_dismiss.clone()),
859 );
860 EventResponse::Handled
861 }
862 _ => EventResponse::Ignored,
863 }
864 })
865 .on_access_action({
866 let label = self.label.clone();
867 let content_factory = content_factory.clone();
868 let enabled = enabled.clone();
869 move |action, ctx| {
870 if action == teksilo_core::accesskit::Action::Click && enabled.get() {
871 action_open.set(true);
872 queue_dialog_request(
873 ctx,
874 &content_factory,
875 presentation,
876 close_behavior,
877 &label.resolve_now(),
878 Some(action_dismiss.clone()),
879 );
880 EventResponse::Handled
881 } else {
882 EventResponse::Ignored
883 }
884 }
885 }),
886 )
887 };
888
889 self.root_child_id = Some(root_id);
890 vec![root_id]
891 }
892
893 fn layout_response(
894 &self,
895 proposal: SizeProposal,
896 ctx: &LayoutContext,
897 ) -> teksilo_core::widget::LayoutResponse {
898 self.root_child_id
899 .and_then(|id| ctx.child_size(id, proposal))
900 .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
901 .into()
902 }
903
904 fn place_children(
905 &self,
906 bounds: Rect,
907 _proposal: SizeProposal,
908 children: &mut [WidgetPlacement],
909 _ctx: &LayoutContext,
910 ) {
911 for child in children.iter_mut() {
912 child.origin = bounds.origin();
913 child.size = bounds.size();
914 }
915 }
916
917 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
918 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
919 }
920
921 fn children(&self) -> Vec<WidgetId> {
922 self.root_child_id.into_iter().collect()
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use teksilo_canvas::Size;
930 use teksilo_core::widget_tree::WidgetTree;
931 use teksilo_core::{ModalContent, ModalPresentation};
932 use teksilo_i18n::lit;
933
934 #[derive(Debug)]
935 struct FixedLeaf(f32, f32);
936
937 impl Widget for FixedLeaf {
938 fn layout_response(
939 &self,
940 _proposal: SizeProposal,
941 _ctx: &LayoutContext,
942 ) -> teksilo_core::widget::LayoutResponse {
943 Size::new(self.0, self.1).into()
944 }
945 }
946
947 #[test]
948 fn access_click_opens_centered_dialog_overlay() {
949 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
950 tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
951 tree.layout(SizeProposal::exact(800.0, 600.0));
952
953 let trigger = tree.find_by_label("Open dialog").unwrap();
954 tree.dispatch_event(WidgetEvent::AccessAction {
955 action: teksilo_core::accesskit::Action::Click,
956 target: Some(trigger),
957 target_node: teksilo_core::accessibility::root_node_id(),
958 data: None,
959 });
960
961 let requests = tree.drain_pending_modal_requests();
962 assert_eq!(requests.len(), 1);
963 assert_eq!(requests[0].request.presentation, ModalPresentation::Auto);
964 assert_eq!(
965 requests[0].request.close_behavior,
966 ModalCloseBehavior::EscapeOrClickOutside,
967 );
968 assert!(matches!(
969 requests[0].request.content,
970 ModalContent::Deferred(_)
971 ));
972 }
973
974 #[test]
975 fn dialog_surface_exposes_dialog_role() {
976 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
977 tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
978 tree.layout(SizeProposal::exact(800.0, 600.0));
979
980 let trigger = tree.find_by_label("Open dialog").unwrap();
981 tree.dispatch_event(WidgetEvent::AccessAction {
982 action: teksilo_core::accesskit::Action::Click,
983 target: Some(trigger),
984 target_node: teksilo_core::accessibility::root_node_id(),
985 data: None,
986 });
987
988 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
989 let content_id = match request.content {
990 ModalContent::Deferred(builder) => builder(&mut tree),
991 ModalContent::ExistingWidget(_) => {
992 unreachable!("dialog now always uses deferred content")
993 }
994 };
995 tree.layout(SizeProposal::exact(800.0, 600.0));
996
997 let dialog = tree
998 .find_by_role(teksilo_core::accesskit::Role::Dialog)
999 .unwrap();
1000 let info = tree.accessibility_node(dialog);
1001 assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1002 assert!(tree.bounds(content_id).width > 0.0);
1003 }
1004
1005 fn announced_name(tree: &mut WidgetTree, id: WidgetId) -> Option<String> {
1009 let update = tree.sync_accessibility();
1010 let target = teksilo_core::accessibility::widget_id_to_node_id(id);
1011 let consumer = accesskit_consumer::Tree::new(update, false);
1012 let state = consumer.state();
1013 let mut stack = vec![state.root()];
1014 while let Some(node) = stack.pop() {
1015 if node.locate().0 == target {
1016 return node.label();
1017 }
1018 for child in node.children() {
1019 stack.push(child);
1020 }
1021 }
1022 None
1023 }
1024
1025 #[test]
1026 fn modal_container_is_named_by_the_title_its_content_paints() {
1027 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1031 let container = tree.add(ModalContainer::new(
1032 DialogContent::new()
1033 .title(lit!("Delete file?"))
1034 .body(FixedLeaf(100.0, 40.0)),
1035 ));
1036 tree.layout(SizeProposal::exact(600.0, 400.0));
1037 assert_eq!(
1038 tree.accessibility_node(container).role(),
1039 teksilo_core::accesskit::Role::Dialog
1040 );
1041 assert_eq!(
1042 announced_name(&mut tree, container).as_deref(),
1043 Some("Delete file?")
1044 );
1045 }
1046
1047 #[test]
1048 fn an_explicit_title_yields_to_the_one_on_screen() {
1049 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1055 let container = tree.add(
1056 ModalContainer::new(
1057 DialogContent::new()
1058 .title(lit!("Inner title"))
1059 .body(FixedLeaf(100.0, 40.0)),
1060 )
1061 .title(lit!("Outer title")),
1062 );
1063 tree.layout(SizeProposal::exact(600.0, 400.0));
1064 assert_eq!(
1065 announced_name(&mut tree, container).as_deref(),
1066 Some("Inner title")
1067 );
1068
1069 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1070 let untitled = tree.add(
1071 ModalContainer::new(DialogContent::new().body(FixedLeaf(100.0, 40.0)))
1072 .title(lit!("Outer title")),
1073 );
1074 tree.layout(SizeProposal::exact(600.0, 400.0));
1075 assert_eq!(
1076 announced_name(&mut tree, untitled).as_deref(),
1077 Some("Outer title")
1078 );
1079 }
1080
1081 #[derive(Debug)]
1088 struct HintingPanel {
1089 first: Rc<std::cell::Cell<Option<WidgetId>>>,
1090 hinted: Rc<std::cell::Cell<Option<WidgetId>>>,
1091 }
1092
1093 impl Widget for HintingPanel {
1094 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1095 let first = ctx.add(FixedLeaf(40.0, 20.0));
1096 let hinted = ctx.add(FixedLeaf(40.0, 20.0));
1097 self.first.set(Some(first));
1098 self.hinted.set(Some(hinted));
1099 vec![first, hinted]
1100 }
1101
1102 fn initial_focus_hint(&self) -> Option<WidgetId> {
1103 self.hinted.get()
1104 }
1105
1106 fn layout_response(
1107 &self,
1108 _proposal: SizeProposal,
1109 _ctx: &LayoutContext,
1110 ) -> teksilo_core::widget::LayoutResponse {
1111 Size::new(220.0, 120.0).into()
1112 }
1113 }
1114
1115 #[test]
1128 fn modal_container_lets_its_content_direct_initial_focus() {
1129 let first = Rc::new(std::cell::Cell::new(None));
1130 let hinted = Rc::new(std::cell::Cell::new(None));
1131
1132 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1133 let container = tree.add(ModalContainer::new(HintingPanel {
1134 first: first.clone(),
1135 hinted: hinted.clone(),
1136 }));
1137 tree.layout(SizeProposal::exact(600.0, 400.0));
1138
1139 let target = tree.widget_initial_focus_hint(container);
1140 assert_eq!(
1141 target,
1142 hinted.get(),
1143 "the content's hint must survive being wrapped in a ModalContainer"
1144 );
1145 assert_ne!(
1146 target,
1147 first.get(),
1148 "…and must not fall back to the first descendant, which is the \
1149 close button in a real dialog"
1150 );
1151 }
1152
1153 #[test]
1157 fn modal_container_without_a_hint_reports_none() {
1158 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1159 let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1160 tree.layout(SizeProposal::exact(600.0, 400.0));
1161
1162 assert_eq!(tree.widget_initial_focus_hint(container), None);
1163 }
1164
1165 #[test]
1166 fn modal_container_preserves_shell_sizing_defaults() {
1167 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1168 let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1169 tree.layout(SizeProposal {
1170 width: None,
1171 height: None,
1172 });
1173
1174 let bounds = tree.bounds(container);
1177 assert!((bounds.width - 280.0).abs() < 0.01);
1178 assert!((bounds.height - 168.0).abs() < 0.01);
1179 }
1180
1181 #[test]
1182 fn modal_container_custom_padding_changes_layout() {
1183 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184 let container = tree.add(
1185 ModalContainer::new(FixedLeaf(220.0, 120.0))
1186 .padding(12.0)
1187 .min_width(200.0),
1188 );
1189 tree.layout(SizeProposal {
1190 width: None,
1191 height: None,
1192 });
1193
1194 let bounds = tree.bounds(container);
1195 assert!((bounds.width - 244.0).abs() < 0.01);
1196 assert!((bounds.height - 144.0).abs() < 0.01);
1197 }
1198
1199 #[test]
1200 fn custom_trigger_opens_dialog_overlay() {
1201 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1202 tree.add(
1203 Dialog::new(lit!("Open dialog"))
1204 .content(|| FixedLeaf(220.0, 120.0))
1205 .trigger(FixedLeaf(140.0, 40.0)),
1206 );
1207 tree.layout(SizeProposal::exact(800.0, 600.0));
1208
1209 let trigger = tree.find_by_label("Open dialog").unwrap();
1215 tree.click(trigger);
1216
1217 assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1218 }
1219
1220 #[test]
1221 fn a_custom_trigger_advertises_and_answers_the_at_click() {
1222 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1229 tree.add(
1230 Dialog::new(lit!("Open dialog"))
1231 .content(|| FixedLeaf(220.0, 120.0))
1232 .trigger(FixedLeaf(140.0, 40.0)),
1233 );
1234 tree.layout(SizeProposal::exact(800.0, 600.0));
1235
1236 let trigger = tree.find_by_label("Open dialog").unwrap();
1237 assert_eq!(
1238 tree.accessibility_node(trigger).role(),
1239 teksilo_core::accesskit::Role::Button
1240 );
1241 assert!(
1242 tree.accessibility_node(trigger)
1243 .actions()
1244 .contains(&teksilo_core::accesskit::Action::Click),
1245 "the node carrying Role::Button must advertise Click"
1246 );
1247
1248 let handled = tree.dispatch_access_action(
1252 teksilo_core::accessibility::widget_id_to_node_id(trigger),
1253 teksilo_core::accesskit::Action::Click,
1254 None,
1255 &mut teksilo_core::NoopWindowOps,
1256 );
1257 assert!(handled, "the AT Click must be reported as handled");
1258 assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1259 }
1260
1261 #[test]
1262 fn rebuilding_a_custom_trigger_does_not_stack_its_at_handler() {
1263 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1268 let d = tree.add(
1269 Dialog::new(lit!("Open dialog"))
1270 .content(|| FixedLeaf(220.0, 120.0))
1271 .trigger(FixedLeaf(140.0, 40.0)),
1272 );
1273 tree.layout(SizeProposal::exact(800.0, 600.0));
1274 for _ in 0..3 {
1275 tree.arena_mark_needs_rebuild_for_testing(d);
1276 tree.layout(SizeProposal::exact(800.0, 600.0));
1277 }
1278 let trigger = tree.find_by_label("Open dialog").unwrap();
1279 tree.dispatch_access_action(
1280 teksilo_core::accessibility::widget_id_to_node_id(trigger),
1281 teksilo_core::accesskit::Action::Click,
1282 None,
1283 &mut teksilo_core::NoopWindowOps,
1284 );
1285 assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1286 }
1287
1288 #[test]
1289 fn dialog_content_helper_builds_dialog_sections() {
1290 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1291 tree.add(Dialog::new(lit!("Open dialog")).content(|| {
1292 DialogContent::new()
1293 .title(lit!("Review Changes"))
1294 .supporting_text(lit!("Confirm the staged updates before continuing."))
1295 .body(FixedLeaf(220.0, 120.0))
1296 .footer(Button::new(lit!("Close")))
1297 }));
1298 tree.layout(SizeProposal::exact(800.0, 600.0));
1299
1300 let trigger = tree.find_by_label("Open dialog").unwrap();
1301 tree.dispatch_event(WidgetEvent::AccessAction {
1302 action: teksilo_core::accesskit::Action::Click,
1303 target: Some(trigger),
1304 target_node: teksilo_core::accessibility::root_node_id(),
1305 data: None,
1306 });
1307
1308 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1309 match request.content {
1310 ModalContent::Deferred(builder) => {
1311 builder(&mut tree);
1312 }
1313 ModalContent::ExistingWidget(_) => {
1314 unreachable!("dialog now always uses deferred content")
1315 }
1316 }
1317 tree.layout(SizeProposal::exact(800.0, 600.0));
1318
1319 assert!(tree.find_by_label("Review Changes").is_some());
1320 assert!(tree.find_by_label("Close").is_some());
1321 }
1322
1323 #[test]
1324 fn dialog_presentation_can_be_overridden() {
1325 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1326 tree.add(
1327 Dialog::new(lit!("Open dialog"))
1328 .content(|| FixedLeaf(220.0, 120.0))
1329 .presentation(ModalPresentation::InTree),
1330 );
1331 tree.layout(SizeProposal::exact(800.0, 600.0));
1332
1333 let trigger = tree.find_by_label("Open dialog").unwrap();
1334 tree.dispatch_event(WidgetEvent::AccessAction {
1335 action: teksilo_core::accesskit::Action::Click,
1336 target: Some(trigger),
1337 target_node: teksilo_core::accessibility::root_node_id(),
1338 data: None,
1339 });
1340
1341 let requests = tree.drain_pending_modal_requests();
1342 assert_eq!(requests.len(), 1);
1343 assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
1344 }
1345
1346 #[test]
1347 fn dialog_close_behavior_can_be_overridden() {
1348 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1349 tree.add(
1350 Dialog::new(lit!("Open dialog"))
1351 .content(|| FixedLeaf(220.0, 120.0))
1352 .close_behavior(ModalCloseBehavior::Manual),
1353 );
1354 tree.layout(SizeProposal::exact(800.0, 600.0));
1355
1356 let trigger = tree.find_by_label("Open dialog").unwrap();
1357 tree.dispatch_event(WidgetEvent::AccessAction {
1358 action: teksilo_core::accesskit::Action::Click,
1359 target: Some(trigger),
1360 target_node: teksilo_core::accessibility::root_node_id(),
1361 data: None,
1362 });
1363
1364 let requests = tree.drain_pending_modal_requests();
1365 assert_eq!(requests.len(), 1);
1366 assert_eq!(
1367 requests[0].request.close_behavior,
1368 ModalCloseBehavior::Manual
1369 );
1370 }
1371
1372 #[test]
1373 #[should_panic(expected = "Dialog requires .content(...)")]
1374 fn dialog_without_content_panics_on_build() {
1375 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1376 tree.add(Dialog::new(lit!("Open dialog")));
1377 tree.layout(SizeProposal::exact(800.0, 600.0));
1378 }
1379}
1380
1381#[cfg(test)]
1382mod scrim_hit_targeting_tests {
1383 use super::*;
1384 use teksilo_canvas::Point;
1385 use teksilo_core::pointer::{EventTime, PointerId, PointerInfo};
1386 use teksilo_core::widget_tree::WidgetTree;
1387 use teksilo_tokens::TargetDensity;
1388
1389 #[test]
1398 fn a_modal_scrim_never_participates_in_hit_widening() {
1399 use crate::primitives::{FixedSize, ZStack};
1400 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1401 tree.set_input_density(TargetDensity::Touch);
1402 let scrim = tree.add(
1403 FixedSize::new()
1404 .width(20.0)
1405 .height(20.0)
1406 .child(ModalScrim::new().click_to_dismiss(true)),
1407 );
1408 tree.add(ZStack::new().child(scrim));
1409 tree.layout(SizeProposal::exact(200.0, 200.0));
1410
1411 let b = tree.bounds(scrim);
1412 assert_eq!(
1413 b.size(),
1414 Size::new(20.0, 20.0),
1415 "the fixture needs a SMALL scrim"
1416 );
1417 let finger = PointerInfo::touch(PointerId::MOUSE, EventTime::ZERO);
1418 let probe = Point::new(b.right() + 4.0, b.center().y);
1421 let hit = tree.hit_test_for(probe, &finger);
1422 let claimed_by_scrim = hit.is_some_and(|id| {
1423 std::iter::successors(Some(id), |id| tree.parent(*id)).any(|id| id == scrim)
1424 });
1425 assert!(
1426 !claimed_by_scrim,
1427 "a press outside the scrim reached {hit:?}, inside the scrim subtree"
1428 );
1429 }
1430}