Skip to main content

tessera_components/
dialog.rs

1//! Modal dialog provider — show modal content above the main app UI.
2//!
3//! ## Usage
4//!
5//! Show blocking alerts, confirmations, wizards and forms.
6
7use 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/// The purpose announced for a modal dialog.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum DialogRole {
43    /// A modal task, form, or informational dialog.
44    #[default]
45    Dialog,
46    /// An urgent message that requires an immediate response.
47    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/// The registration target supplied to a dialog provider's content.
66#[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
107/// Controller for [`dialog_provider`], controlling visibility and animation.
108pub struct DialogController {
109    is_open: bool,
110    motion: VisibilityMotion,
111}
112
113impl DialogController {
114    /// Creates a new dialog controller.
115    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    /// Opens the dialog, starting the animation if necessary.
123    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    /// Closes the dialog, starting the closing animation if necessary.
131    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    /// Returns whether the dialog is currently open.
139    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/// # dialog_provider
337///
338/// Provide a modal dialog at the top level of an application.
339///
340/// ## Usage
341///
342/// Show modal content for alerts, confirmation dialogs, multi-step forms, or
343/// onboarding steps that require blocking user interaction with the main UI.
344///
345/// ## Parameters
346///
347/// - `accessibility_label` — accessible name overriding the standard content
348///   title.
349/// - `accessibility_description` — description overriding basic supporting
350///   text; provide this for custom content that needs a concise accessible
351///   summary.
352/// - `role` — dialog purpose; use [`DialogRole::AlertDialog`] for an urgent
353///   message that requires a response. Defaults to [`DialogRole::Dialog`].
354/// - `on_close_request` — optional close-request callback.
355/// - `padding` — padding applied inside the dialog surface.
356/// - `full_screen` — fill the available height without outer margins or rounded
357///   corners; width remains limited to 560dp.
358/// - `is_open` — declarative open state.
359/// - `controller` — optional external controller.
360/// - `main_content` — optional main content rendered behind the dialog.
361/// - `dialog_content` — modal content receiving its explicit dialog scope.
362///
363/// ## Examples
364///
365/// ```
366/// # use tessera_ui::tessera;
367/// # #[tessera]
368/// # fn component() {
369/// use tessera_components::dialog::{basic_dialog, dialog_provider};
370/// # use tessera_components::theme::{MaterialTheme, material_theme};
371///
372/// # material_theme()
373/// #     .theme(|| MaterialTheme::default())
374/// #     .child(|| {
375/// dialog_provider()
376///     .accessibility_label("Dialog Title")
377///     .on_close_request(|| {})
378///     .is_open(true)
379///     .main_content(|| { /* main content */ })
380///     .dialog_content(|dialog| {
381///         basic_dialog().dialog(dialog)
382///             .supporting_text("This is the dialog body text.")
383///             .headline("Dialog Title");
384///     });
385/// #     });
386/// # }
387/// # tessera_ui::testing::layout_test(component).run();
388/// ```
389#[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    // In controlled mode (external controller provided), do not override
414    // controller state from `is_open`.
415    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/// # full_screen_dialog
533///
534/// Present a scrollable form with a close action and optional save action.
535///
536/// ## Usage
537///
538/// Edit information or complete a task that needs the available screen height.
539///
540/// ## Parameters
541///
542/// - `on_close` — callback invoked by the close button.
543/// - `modifier` — modifier chain applied to the content surface.
544/// - `title` — optional headline in the top bar.
545/// - `confirm_label` — text for the optional confirm action, defaulting to
546///   Save.
547/// - `on_confirm` — optional callback; omitting it hides the confirm action.
548/// - `content` — scrollable form or task content.
549///
550/// ## Examples
551///
552/// ```
553/// use tessera_components::{
554///     dialog::{dialog_provider, full_screen_dialog},
555///     text::text,
556///     theme::material_theme,
557/// };
558/// use tessera_ui::{Callback, remember, tessera};
559/// #[tessera]
560/// fn example() {
561///     material_theme().child(|| {
562///         let open = remember(|| true);
563///         let close = Callback::new(move || open.set(false));
564///         dialog_provider()
565///             .is_open(open.get())
566///             .full_screen(true)
567///             .on_close_request_shared(close)
568///             .main_content(|| {
569///                 text("Calendar");
570///             })
571///             .dialog_content(move |dialog| {
572///                 full_screen_dialog(close).dialog(dialog).title("New event").content(|| {
573///                     text("Event details");
574///                 });
575///             });
576///         close.call();
577///         assert!(!open.get());
578///     });
579/// }
580/// # tessera_ui::testing::layout_test(example).run();
581/// ```
582#[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/// # basic_dialog
654///
655/// Present a brief confirmation or alert with a scrollable message body.
656///
657/// ## Usage
658///
659/// Use inside the `dialog_content` closure of [`dialog_provider`].
660///
661/// ## Parameters
662///
663/// - `dialog` — registration scope received from the provider content slot.
664/// - `icon` — optional icon slot shown above the headline.
665/// - `headline` — optional headline text.
666/// - `supporting_text` — body text shown in the dialog.
667/// - `content` — optional custom body replacing the supporting text.
668///   Overflowing content scrolls while the headline and actions remain visible.
669/// - `confirm_button` — optional confirm button slot.
670/// - `dismiss_button` — optional dismiss button slot.
671///
672/// ## Examples
673///
674/// ```
675/// # use tessera_ui::tessera;
676/// # #[tessera]
677/// # fn component() {
678/// use tessera_components::{button::button, dialog::basic_dialog, text::text};
679/// # use tessera_components::theme::{MaterialTheme, material_theme};
680///
681/// # material_theme()
682/// #     .theme(|| MaterialTheme::default())
683/// #     .child(|| {
684/// basic_dialog()
685///     .supporting_text("This is the dialog body text.")
686///     .headline("Dialog Title")
687///     .confirm_button(|| {
688///         button().filled().on_click(|| {}).child(|| {
689///             text("Confirm");
690///         });
691///     });
692/// #     });
693/// # }
694/// # tessera_ui::testing::layout_test(component).run();
695/// ```
696#[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;