1use tessera_ui::{
8 AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusProperties, FocusScopeNode,
9 FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition, RenderSlot,
10 RenderSlotWith, State,
11 accesskit::Role,
12 current_frame_nanos,
13 layout::{MeasureScope, layout},
14 modifier::FocusModifierExt as _,
15 provide_context, receive_frame_nanos, remember, remember_with_key, tessera, use_context, winit,
16};
17
18use crate::{
19 alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
20 animation::visibility_motion::{VisibilityMotion, VisibilitySample, modal_back_handler},
21 app_bar::top_app_bar,
22 boxed::boxed,
23 button::button,
24 column::column,
25 flow_row::flow_row,
26 icon_button::{IconButtonColorStyle, icon_button},
27 material_icons::filled,
28 modal_focus::{ModalFocusState, modal_focus_modifier},
29 modifier::{ModifierExt, SemanticsArgs, with_keyboard_input},
30 overlay_semantics::overlay_background,
31 res::I18N,
32 scrollable::ScrollableController,
33 shape_def::{RoundedCorner, Shape},
34 spacer::spacer,
35 surface::surface,
36 text::text,
37 theme::{ContentColor, MaterialTheme},
38};
39
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum DialogRole {
43 #[default]
45 Dialog,
46 AlertDialog,
48}
49
50impl DialogRole {
51 fn accessibility_role(self) -> Role {
52 match self {
53 Self::Dialog => Role::Dialog,
54 Self::AlertDialog => Role::AlertDialog,
55 }
56 }
57}
58
59#[derive(Clone, Default, PartialEq)]
60struct DialogContentAccessibility {
61 label: Option<String>,
62 description: Option<String>,
63}
64
65#[derive(Clone, Copy, PartialEq, Eq, Hash)]
67pub struct DialogScope {
68 content: State<Option<State<DialogContentAccessibility>>>,
69}
70
71struct DialogAccessibilityRegistration {
72 context: DialogScope,
73 content: State<DialogContentAccessibility>,
74}
75
76impl Drop for DialogAccessibilityRegistration {
77 fn drop(&mut self) {
78 if self.context.content.is_alive() && self.context.content.get() == Some(self.content) {
79 self.context.content.set(None);
80 }
81 }
82}
83
84#[tessera]
85pub(crate) fn register_dialog_accessibility(
86 dialog: Option<DialogScope>,
87 label: Option<String>,
88 description: Option<String>,
89) {
90 let Some(context) = dialog else {
91 return;
92 };
93 let content = remember(DialogContentAccessibility::default);
94 let next = DialogContentAccessibility { label, description };
95 if content.get() != next {
96 content.set(next);
97 }
98 remember_with_key(context, move || DialogAccessibilityRegistration {
99 context,
100 content,
101 });
102 if context.content.get() != Some(content) {
103 context.content.set(Some(content));
104 }
105}
106
107pub struct DialogController {
109 is_open: bool,
110 motion: VisibilityMotion,
111}
112
113impl DialogController {
114 pub fn new(initial_open: bool) -> Self {
116 Self {
117 is_open: initial_open,
118 motion: VisibilityMotion::new(initial_open, false),
119 }
120 }
121
122 pub fn open(&mut self) {
124 if !self.is_open {
125 self.motion.retarget(true, current_frame_nanos());
126 self.is_open = true;
127 }
128 }
129
130 pub fn close(&mut self) {
132 if self.is_open {
133 self.motion.retarget(false, current_frame_nanos());
134 self.is_open = false;
135 }
136 }
137
138 pub fn is_open(&self) -> bool {
140 self.is_open
141 }
142
143 fn is_animating(&self) -> bool {
144 self.motion.sample(current_frame_nanos()).active
145 }
146
147 fn snapshot(&self) -> (bool, VisibilitySample) {
148 (self.is_open, self.motion.sample(current_frame_nanos()))
149 }
150}
151
152impl Default for DialogController {
153 fn default() -> Self {
154 Self::new(false)
155 }
156}
157
158fn make_keyboard_handler(
159 on_close: Callback,
160) -> Box<dyn for<'a> Fn(tessera_ui::KeyboardInput<'a>) + Send + Sync + 'static> {
161 Box::new(move |mut input| {
162 let mut handled = false;
163 input.keyboard_events.retain(|event| {
164 if event.state != winit::event::ElementState::Pressed {
165 return true;
166 }
167
168 if let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
169 event.physical_key
170 {
171 on_close.call();
172 handled = true;
173 return false;
174 }
175
176 true
177 });
178 if handled {
179 input.block_keyboard();
180 }
181 })
182}
183
184#[tessera]
185fn dialog_content_wrapper(
186 accessibility_label: Option<String>,
187 accessibility_description: Option<String>,
188 role: Option<DialogRole>,
189 alpha: Option<f32>,
190 padding: Option<Dp>,
191 just_opened: Option<bool>,
192 on_close_request: Option<Callback>,
193 content: Option<RenderSlotWith<DialogScope>>,
194 focus_scope: Option<FocusScopeNode>,
195 full_screen: Option<bool>,
196) {
197 let full_screen = full_screen.unwrap_or(false);
198 let alpha = alpha.unwrap_or(1.0);
199 let padding = padding.unwrap_or(Dp(24.0));
200 let just_opened = just_opened.unwrap_or(false);
201 let on_close_request = on_close_request.unwrap_or_default();
202 let focus_scope = focus_scope.expect("modal content requires its focus scope");
203 let context = DialogScope {
204 content: remember(|| None),
205 };
206 let inferred = context
207 .content
208 .get()
209 .filter(|content| content.is_alive())
210 .map(|content| content.get())
211 .unwrap_or_default();
212 let modifier = with_keyboard_input(
213 Modifier::new()
214 .focus_scope_with(focus_scope)
215 .focus_properties(FocusProperties::new().skip_traversal(true))
216 .focus_traversal_policy(
217 FocusTraversalPolicy::linear()
218 .wrap(true)
219 .tab_navigation(true),
220 ),
221 make_keyboard_handler(on_close_request),
222 )
223 .semantics(SemanticsArgs {
224 role: Some(role.unwrap_or_default().accessibility_role()),
225 label: accessibility_label.or(inferred.label),
226 description: accessibility_description.or(inferred.description),
227 modal: true,
228 focusable: true,
229 merge_descendants: true,
230 ..Default::default()
231 });
232 if just_opened {
233 focus_scope.request_focus();
234 }
235 let policy = DialogContentLayout { alpha };
236 layout().policy(policy.clone()).draw(policy).child(move || {
237 let content = content;
238 let modifier = modifier.clone();
239 boxed()
240 .modifier(Modifier::new().fill_max_size())
241 .alignment(Alignment::Center)
242 .children(move || {
243 let content = content;
244 let modifier = modifier.clone();
245 surface()
246 .style(Color::TRANSPARENT.into())
247 .modifier(
248 Modifier::new()
249 .constrain(Some(AxisConstraint::NONE), Some(AxisConstraint::NONE))
250 .padding_all(if full_screen { Dp::ZERO } else { Dp(24.0) }),
251 )
252 .child(move || {
253 let content = content;
254 surface()
255 .modifier(if full_screen {
256 modifier.clone().width(Dp(560.0)).fill_max_height()
257 } else {
258 modifier.clone()
259 })
260 .style(
261 use_context::<MaterialTheme>()
262 .expect("MaterialTheme must be provided")
263 .get()
264 .color_scheme
265 .surface_container_high
266 .into(),
267 )
268 .elevation(if full_screen { Dp::ZERO } else { Dp(6.0) })
269 .shape(if full_screen {
270 Shape::RECTANGLE
271 } else {
272 Shape::RoundedRectangle {
273 top_left: RoundedCorner::manual(Dp(28.0), 3.0),
274 top_right: RoundedCorner::manual(Dp(28.0), 3.0),
275 bottom_right: RoundedCorner::manual(Dp(28.0), 3.0),
276 bottom_left: RoundedCorner::manual(Dp(28.0), 3.0),
277 }
278 })
279 .block_input(true)
280 .child(move || {
281 let content = content;
282 layout()
283 .modifier(Modifier::new().padding_all(padding))
284 .child(move || {
285 if let Some(content) = content {
286 content.render(context);
287 }
288 });
289 });
290 });
291 });
292 });
293}
294
295#[derive(Clone, PartialEq)]
296struct DialogContentLayout {
297 alpha: f32,
298}
299
300impl tessera_ui::LayoutModifierNode for DialogContentLayout {
301 fn measure_eq(&self, _other: &Self) -> bool {
302 true
303 }
304
305 fn placement_eq(&self, _other: &Self) -> bool {
306 true
307 }
308
309 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
310 let mut result = LayoutResult::default();
311 let Some(child) = input.children().first().copied() else {
312 return Ok(result.with_size(ComputedData {
313 width: Px(0),
314 height: Px(0),
315 }));
316 };
317 let child_constraint = input.parent_constraint().without_min();
318 let computed = child.measure(input, &child_constraint)?;
319 result.place_child(child, PxPosition::ZERO);
320 Ok(result.with_size(computed.size()))
321 }
322}
323
324impl tessera_ui::DrawModifierNode for DialogContentLayout {
325 fn draw(
326 &self,
327 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
328 content: &mut dyn tessera_ui::DrawModifierContent,
329 ) {
330 let input = &mut *ctx.render_input;
331 input.metadata_mut().multiply_opacity(self.alpha);
332 content.draw(input);
333 }
334}
335
336#[tessera]
390pub fn dialog_provider(
391 #[prop(into)] accessibility_label: Option<String>,
392 #[prop(into)] accessibility_description: Option<String>,
393 role: Option<DialogRole>,
394 on_close_request: Option<Callback>,
395 padding: Option<Dp>,
396 full_screen: Option<bool>,
397 is_open: Option<bool>,
398 #[prop(skip_setter)] controller: Option<State<DialogController>>,
399 main_content: Option<RenderSlot>,
400 dialog_content: Option<RenderSlotWith<DialogScope>>,
401) {
402 let on_close_request = on_close_request.unwrap_or_default();
403 let full_screen = full_screen.unwrap_or(false);
404 let padding = padding.unwrap_or(if full_screen { Dp::ZERO } else { Dp(24.0) });
405 let is_open = is_open.unwrap_or(false);
406 let main_content = main_content.unwrap_or_else(RenderSlot::empty);
407 let external_controller = controller;
408 let controller = match external_controller {
409 Some(value) => value,
410 None => remember(|| DialogController::new(is_open)),
411 };
412
413 if external_controller.is_none() {
416 let current_open = controller.with(|c| c.is_open());
417 if is_open != current_open {
418 if is_open {
419 controller.with_mut(|c| c.open());
420 } else {
421 controller.with_mut(|c| c.close());
422 }
423 }
424 }
425 dialog_provider_render()
426 .accessibility_label_optional(accessibility_label)
427 .accessibility_description_optional(accessibility_description)
428 .role(role.unwrap_or_default())
429 .on_close_request_shared(on_close_request)
430 .padding(padding)
431 .full_screen(full_screen)
432 .controller(controller)
433 .main_content_shared(main_content)
434 .dialog_content_optional(dialog_content);
435}
436
437#[tessera]
438fn dialog_provider_render(
439 accessibility_label: Option<String>,
440 accessibility_description: Option<String>,
441 role: Option<DialogRole>,
442 on_close_request: Option<Callback>,
443 padding: Option<Dp>,
444 full_screen: Option<bool>,
445 controller: Option<State<DialogController>>,
446 main_content: Option<RenderSlot>,
447 dialog_content: Option<RenderSlotWith<DialogScope>>,
448) {
449 let on_close_request = on_close_request.unwrap_or_default();
450 let padding = padding.unwrap_or(Dp(24.0));
451 let controller = controller.expect("dialog_provider_render requires controller");
452 let main_content = main_content.unwrap_or_else(RenderSlot::empty);
453 let dialog_open_state = remember(|| false);
454
455 let (is_open, visibility) = controller.with(|c| c.snapshot());
456 let mut just_opened = false;
457 dialog_open_state.with_mut(|was_open| {
458 just_opened = !*was_open && is_open;
459 *was_open = is_open;
460 });
461
462 let is_animating = controller.with(|c| c.is_animating());
463 if is_animating {
464 receive_frame_nanos(move |frame_nanos| {
465 let is_animating =
466 controller.with(|controller| controller.motion.sample(frame_nanos).active);
467 if is_animating {
468 tessera_ui::FrameNanosControl::Continue
469 } else {
470 tessera_ui::FrameNanosControl::Stop
471 }
472 });
473 }
474
475 let focus_scope = remember(FocusScopeNode::new).get();
476 let focus_state = remember(ModalFocusState::default);
477 let visible = is_open || is_animating;
478 let modifier = modal_focus_modifier(Modifier::new(), focus_state, visible, focus_scope);
479 layout().modifier(modifier).child(move || {
480 overlay_background(main_content, visible);
481 modal_back_handler(is_open, on_close_request).on_preview(move |event| {
482 controller.with_mut(|controller| {
483 controller.motion.preview_back(event, current_frame_nanos());
484 });
485 });
486 if is_open || is_animating {
487 let content_alpha = visibility.opacity;
488 let alpha = visibility.opacity * 0.32;
489 let scrim_color = use_context::<MaterialTheme>()
490 .expect("MaterialTheme must be provided")
491 .get()
492 .color_scheme
493 .scrim;
494 surface()
495 .style(scrim_color.with_alpha(alpha).into())
496 .on_click_shared(on_close_request)
497 .show_ripple(false)
498 .show_state_layer(false)
499 .modifier(
500 Modifier::new()
501 .fill_max_size()
502 .semantics(SemanticsArgs {
503 hidden: true,
504 ..Default::default()
505 })
506 .focusable()
507 .focus_properties(
508 FocusProperties::new()
509 .can_focus(false)
510 .can_request_focus(false)
511 .skip_traversal(true),
512 ),
513 )
514 .block_input(true)
515 .child(|| {});
516
517 dialog_content_wrapper()
518 .accessibility_label_optional(accessibility_label.clone())
519 .accessibility_description_optional(accessibility_description.clone())
520 .role(role.unwrap_or_default())
521 .focus_scope(focus_scope)
522 .alpha(content_alpha)
523 .padding(padding)
524 .full_screen(full_screen.unwrap_or(false))
525 .just_opened(just_opened)
526 .on_close_request_shared(on_close_request)
527 .content_optional(dialog_content);
528 }
529 });
530}
531
532#[tessera]
583pub fn full_screen_dialog(
584 on_close: Callback,
585 dialog: Option<DialogScope>,
586 modifier: Option<Modifier>,
587 #[prop(into)] title: Option<String>,
588 #[prop(into)] confirm_label: Option<String>,
589 on_confirm: Option<Callback>,
590 content: Option<RenderSlot>,
591) {
592 register_dialog_accessibility()
593 .dialog_optional(dialog)
594 .label_optional(title.clone());
595 let title = title.unwrap_or_default();
596 let confirm_label = confirm_label.unwrap_or_else(|| I18N.text("md3-save"));
597 let content = content.unwrap_or_else(RenderSlot::empty);
598 let scroll = remember(ScrollableController::new);
599 let scheme = use_context::<MaterialTheme>()
600 .expect("MaterialTheme must be provided")
601 .get()
602 .color_scheme;
603 surface()
604 .modifier(modifier.unwrap_or_default().fill_max_size())
605 .style(scheme.surface_container_high.into())
606 .shape(Shape::RECTANGLE)
607 .content_color(scheme.on_surface)
608 .child(move || {
609 let title = title.clone();
610 let confirm_label = confirm_label.clone();
611 column()
612 .modifier(Modifier::new().fill_max_size())
613 .children(move || {
614 let bar = top_app_bar()
615 .title(title.clone())
616 .container_color(scheme.surface_container_high)
617 .navigation_icon(move || {
618 icon_button()
619 .standard()
620 .color_style(IconButtonColorStyle::Inherited)
621 .icon(filled::CLOSE_SVG)
622 .accessibility_label(I18N.text("md3-close-dialog"))
623 .on_click_shared(on_close);
624 });
625 if let Some(on_confirm) = on_confirm {
626 let confirm_label = confirm_label.clone();
627 bar.action(move || {
628 let confirm_label = confirm_label.clone();
629 button()
630 .text()
631 .content_color(scheme.primary)
632 .ripple_color(scheme.primary)
633 .accessibility_label(confirm_label.clone())
634 .on_click_shared(on_confirm)
635 .child(move || {
636 text(confirm_label.clone());
637 });
638 });
639 }
640 column()
641 .modifier(
642 Modifier::new()
643 .fill_max_width()
644 .weight(1.0)
645 .vertical_scroll(scroll)
646 .padding_all(Dp(24.0)),
647 )
648 .children_shared(content);
649 });
650 });
651}
652
653#[tessera]
697pub fn basic_dialog(
698 dialog: Option<DialogScope>,
699 icon: Option<RenderSlot>,
700 #[prop(into)] headline: Option<String>,
701 #[prop(into)] supporting_text: Option<String>,
702 content: Option<RenderSlot>,
703 confirm_button: Option<RenderSlot>,
704 dismiss_button: Option<RenderSlot>,
705) {
706 register_dialog_accessibility()
707 .dialog_optional(dialog)
708 .label_optional(headline.clone())
709 .description_optional(if content.is_none() {
710 supporting_text.clone()
711 } else {
712 None
713 });
714 let supporting_text = supporting_text.unwrap_or_default();
715 let theme = use_context::<MaterialTheme>()
716 .expect("MaterialTheme must be provided")
717 .get();
718 let scheme = theme.color_scheme;
719 let alignment = if icon.is_some() {
720 CrossAxisAlignment::Center
721 } else {
722 CrossAxisAlignment::Start
723 };
724 let scroll = remember(ScrollableController::new);
725
726 layout()
727 .modifier(Modifier::new().constrain(
728 Some(AxisConstraint::new(
729 Dp(232.0).into(),
730 Some(Dp(512.0).into()),
731 )),
732 Some(AxisConstraint::NONE),
733 ))
734 .policy(BasicDialogLayout {
735 center: icon.is_some(),
736 })
737 .child(move || {
738 let headline = headline.clone();
739 let supporting_text = supporting_text.clone();
740 column().cross_axis_alignment(alignment).children(move || {
741 if let Some(icon) = icon.as_ref() {
742 let icon = *icon;
743 let icon_color = scheme.secondary;
744 {
745 provide_context(
746 || ContentColor {
747 current: icon_color,
748 },
749 || {
750 icon.render();
751 },
752 );
753 };
754 {
755 spacer().modifier(Modifier::new().height(Dp(16.0)));
756 };
757 }
758
759 if let Some(headline) = headline.as_ref() {
760 let headline = headline.clone();
761 {
762 text(headline.clone())
763 .style(theme.typography.headline_small)
764 .color(scheme.on_surface);
765 };
766 {
767 spacer().modifier(Modifier::new().height(Dp(16.0)));
768 };
769 }
770 });
771
772 column()
773 .modifier(Modifier::new().vertical_scroll(scroll))
774 .cross_axis_alignment(alignment)
775 .children(move || {
776 if let Some(content) = content {
777 content.render();
778 } else {
779 text(supporting_text.clone())
780 .style(theme.typography.body_medium)
781 .color(scheme.on_surface_variant);
782 }
783 });
784
785 column().children(move || {
786 if confirm_button.is_some() || dismiss_button.is_some() {
787 {
788 spacer().modifier(Modifier::new().height(Dp(24.0)));
789 };
790 let action_color = scheme.primary;
791 {
792 provide_context(
793 || ContentColor {
794 current: action_color,
795 },
796 || {
797 flow_row()
798 .modifier(Modifier::new().fill_max_width())
799 .main_axis_alignment(MainAxisAlignment::End)
800 .item_spacing(Dp(8.0))
801 .line_spacing(Dp(8.0))
802 .children(move || {
803 if let Some(dismiss) = dismiss_button.as_ref() {
804 let dismiss = *dismiss;
805 dismiss.render();
806 }
807
808 if let Some(confirm) = confirm_button.as_ref() {
809 let confirm = *confirm;
810 confirm.render();
811 }
812 });
813 },
814 );
815 };
816 }
817 });
818 });
819}
820
821#[derive(Clone, PartialEq)]
822struct BasicDialogLayout {
823 center: bool,
824}
825
826impl tessera_ui::LayoutModifierNode for BasicDialogLayout {
827 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
828 let children = input.children();
829 let (Some(header), Some(body), Some(actions)) = (
830 children.first().copied(),
831 children.get(1).copied(),
832 children.get(2).copied(),
833 ) else {
834 return Ok(LayoutResult::default());
835 };
836 let parent = input.parent_constraint().without_min();
837 let actions_size = actions.measure(input, &parent)?;
838 let header_constraint = Constraint::new(
839 parent.width,
840 AxisConstraint::new(
841 Px::ZERO,
842 parent
843 .height
844 .resolve_max()
845 .map(|height| (height - actions_size.height).max(Px::ZERO)),
846 ),
847 );
848 let header_size = header.measure(input, &header_constraint)?;
849 let body_constraint = Constraint::new(
850 parent.width,
851 AxisConstraint::new(
852 Px::ZERO,
853 parent.height.resolve_max().map(|height| {
854 (height - actions_size.height - header_size.height).max(Px::ZERO)
855 }),
856 ),
857 );
858 let mut body_size = body.measure(input, &body_constraint)?;
859 let width = input.parent_constraint().width().clamp(
860 header_size
861 .width
862 .max(body_size.width)
863 .max(actions_size.width),
864 );
865 if body_size.width != width {
866 body_size = body.measure(
867 input,
868 &Constraint::new(AxisConstraint::exact(width), body_constraint.height),
869 )?;
870 }
871 let height = input
872 .parent_constraint()
873 .height()
874 .clamp(header_size.height + body_size.height + actions_size.height);
875 let x = |child_width: Px| {
876 if self.center {
877 (width.0 - child_width.0) as f32 * 0.5
878 } else {
879 0.0
880 }
881 };
882 let mut result = LayoutResult::default();
883 result.place_child(header, PxPosition::from_f32_xy(x(header_size.width), 0.0));
884 result.place_child(
885 body,
886 PxPosition::from_f32_xy(x(body_size.width), header_size.height.to_f32()),
887 );
888 result.place_child(
889 actions,
890 PxPosition::from_f32_xy(
891 x(actions_size.width),
892 (header_size.height + body_size.height).to_f32(),
893 ),
894 );
895 Ok(result.with_size(ComputedData { width, height }))
896 }
897}
898
899#[cfg(all(test, not(target_family = "wasm")))]
900mod action_tests;