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