Skip to main content

repose_material/material3/
dialog.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::*;
7use repose_ui::overlay::OverlayHandle;
8use repose_ui::{Box, Column, ViewExt, ZStack};
9use web_time::Duration;
10
11use super::AlertDialogDefaults;
12use super::{DatePicker, DatePickerConfig, DatePickerState};
13use super::{TimePicker, TimePickerConfig, TimePickerState};
14
15static DIALOG_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// State controlling dialog visibility.
18pub struct DialogState {
19    visible: Signal<bool>,
20    id: u64,
21}
22
23impl Default for DialogState {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl DialogState {
30    pub fn new() -> Self {
31        Self {
32            visible: signal(false),
33            id: DIALOG_COUNTER.fetch_add(1, Ordering::Relaxed),
34        }
35    }
36
37    pub fn key(&self, suffix: &str) -> String {
38        format!("dlg_{}_{}", self.id, suffix)
39    }
40
41    pub fn is_visible(&self) -> bool {
42        self.visible.get()
43    }
44
45    pub fn show(&self) {
46        self.visible.set(true);
47    }
48
49    pub fn dismiss(&self) {
50        self.visible.set(false);
51    }
52}
53
54/// Configuration for dialog dismiss behavior.
55/// Mirrors Compose's `DialogProperties`.
56#[derive(Clone)]
57pub struct DialogProperties {
58    /// Called when the user attempts to dismiss the dialog
59    /// (scrim click, Escape/Back press). When set, this overrides `state.dismiss()`.
60    /// To make a dialog that never closes, pass `Some(Rc::new(|| {}))`.
61    pub on_dismiss_request: Option<Rc<dyn Fn()>>,
62    /// Whether clicking the scrim (outside the dialog surface) triggers dismissal.
63    /// Default: `true`.
64    pub dismiss_on_click_outside: bool,
65    /// Whether pressing Escape (or Back gesture) triggers dismissal.
66    /// Default: `true`.
67    pub dismiss_on_back_press: bool,
68    /// Compose `usePlatformDefaultWidth`. Default: true.
69    pub use_platform_default_width: bool,
70    /// Compose `usePlatformInsets` (+ IME). Default: true.
71    pub use_platform_insets: bool,
72}
73
74impl Default for DialogProperties {
75    fn default() -> Self {
76        Self {
77            on_dismiss_request: None,
78            dismiss_on_click_outside: true,
79            dismiss_on_back_press: true,
80            use_platform_default_width: true,
81            use_platform_insets: true,
82        }
83    }
84}
85
86fn preferred_dialog_width_dp(container_w: f32, container_h: f32) -> f32 {
87    let smallest = container_w.min(container_h);
88    if smallest >= 600.0 {
89        super::DialogDefaults::PREFERRED_WIDTH_EXPANDED
90    } else if smallest >= 480.0 {
91        super::DialogDefaults::PREFERRED_WIDTH_MEDIUM
92    } else {
93        super::DialogDefaults::PREFERRED_WIDTH_COMPACT
94    }
95}
96
97fn dialog_available_bounds(
98    use_platform_default_width: bool,
99    use_platform_insets: bool,
100) -> (f32 /*max_w*/, f32 /*max_h*/, PaddingValues) {
101    let win_w = get_window_container_width().max(0.0);
102    let win_h = get_window_container_height().max(0.0);
103
104    let mut pad = PaddingValues::default();
105    if use_platform_insets {
106        let insets = window_insets();
107        // WindowInsets fields are physical px (see ime_padding / system_bars_padding).
108        pad.left = px_to_dp(insets.left);
109        pad.right = px_to_dp(insets.right);
110        pad.top = px_to_dp(insets.top);
111        pad.bottom = px_to_dp(insets.bottom) + px_to_dp(insets.ime_bottom);
112    }
113
114    let avail_w = (win_w - pad.left - pad.right).max(0.0);
115    let avail_h = (win_h - pad.top - pad.bottom).max(0.0);
116
117    let max_w = if use_platform_default_width {
118        preferred_dialog_width_dp(win_w, win_h)
119            .min(avail_w)
120            .min(super::DialogDefaults::MAX_WIDTH)
121    } else {
122        avail_w.min(super::DialogDefaults::MAX_WIDTH)
123    };
124    // Always keep dialog fully on-screen vertically.
125    let max_h = avail_h;
126
127    (max_w.max(0.0), max_h.max(0.0), pad)
128}
129
130/// After merging caller modifiers, clamp size so the dialog can never escape the viewport.
131fn clamp_dialog_modifier(mut m: Modifier, platform_max_w: f32, platform_max_h: f32) -> Modifier {
132    let max_w = m
133        .max_width
134        .unwrap_or(platform_max_w)
135        .min(platform_max_w)
136        .max(0.0);
137    let max_h = m
138        .max_height
139        .unwrap_or(platform_max_h)
140        .min(platform_max_h)
141        .max(0.0);
142    m.max_width = Some(max_w);
143    m.max_height = Some(max_h);
144
145    // Compose Constraints: min cannot exceed max.
146    if let Some(min_w) = m.min_width {
147        m.min_width = Some(min_w.min(max_w).max(0.0));
148    } else {
149        m.min_width = Some(super::DialogDefaults::MIN_WIDTH.min(max_w).max(0.0));
150    }
151    if let Some(min_h) = m.min_height {
152        m.min_height = Some(min_h.min(max_h).max(0.0));
153    }
154    m
155}
156
157/// A modal dialog rendered in the overlay layer with scrim and spring animation.
158///
159/// Unlike the inline `AlertDialog`, this version renders outside the layout tree
160/// so it is never clipped by parent containers, scroll areas, or stacks.
161///
162/// Caller should create a `DialogState` and manage visibility via `show()`/`dismiss()`.
163///
164/// Focus behavior: dialog content is wrapped in a focus group, so Tab/Shift+Tab
165/// cycles within the dialog instead of moving to background elements.
166///
167/// Escape handling: when the dialog content is focused and `dismiss_on_back_press`
168/// is true, pressing Escape calls `on_dismiss_request` (or `state.dismiss()` if
169/// no `on_dismiss_request` is set). Set `dismiss_on_back_press = false` or pass
170/// `on_dismiss_request = Some(Rc::new(|| {}))` to prevent Escape from closing.
171pub fn Dialog(
172    state: Rc<DialogState>,
173    overlay: OverlayHandle,
174    modifier: Modifier,
175    properties: DialogProperties,
176    content: View,
177) -> View {
178    let overlay_id = remember_with_key(state.key("oid"), || signal(0u64));
179
180    // RefCell holding the latest content so the overlay builder reads fresh state each frame
181    let current_content = remember_state_with_key(state.key("c"), || Box(Modifier::new()));
182    *current_content.borrow_mut() = content;
183
184    // Store properties so the overlay closure reads fresh values each frame
185    let props = remember_state_with_key(state.key("p"), || properties.clone());
186    *props.borrow_mut() = properties;
187
188    let scroll_state: Rc<repose_core::scroll::ScrollState> =
189        remember_with_key(state.key("scroll"), repose_core::scroll::ScrollState::new);
190
191    // Animated scale/alpha for enter/exit
192    let spec = AnimationSpec::tween(Duration::from_millis(200), Easing::FastOutSlowIn);
193    let anim = remember_state_with_key(state.key("anim"), || AnimatedValue::new(0.0, spec));
194    let last_target = remember_state_with_key(state.key("atarget"), || f32::NAN);
195    let anim_target = if state.is_visible() { 1.0 } else { 0.0 };
196
197    {
198        let mut a = anim.borrow_mut();
199        let mut lt = last_target.borrow_mut();
200        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
201            a.set_spec(spec);
202            a.set_target(anim_target);
203            *lt = anim_target;
204        }
205        drop(lt);
206        if a.update() {
207            request_frame();
208        }
209    }
210
211    let progress = *anim.borrow().get();
212    let visible = state.is_visible() || progress > 0.01;
213
214    if visible {
215        if overlay_id.get() == 0 {
216            let builder: Rc<dyn Fn() -> View> = Rc::new({
217                let state = state.clone();
218                let anim = anim.clone();
219                let modifier = modifier.clone();
220                let current_content = current_content.clone();
221                let props = props.clone();
222                let scroll_state = scroll_state.clone();
223                move || {
224                    let progress = *anim.borrow().get();
225                    let alpha = progress.min(1.0);
226                    let scale = 0.8 + 0.2 * progress;
227                    let th = theme();
228                    let content = current_content.borrow().clone();
229                    let p = props.borrow().clone();
230
231                    // Recomputed every frame → correct after resize / IME / insets.
232                    let (platform_max_w, platform_max_h, inset_pad) = dialog_available_bounds(
233                        p.use_platform_default_width,
234                        p.use_platform_insets,
235                    );
236
237                    let dialog_mod = clamp_dialog_modifier(
238                        Modifier::new()
239                            .min_width(super::DialogDefaults::MIN_WIDTH)
240                            .max_width(super::DialogDefaults::MAX_WIDTH)
241                            .then(modifier.clone())
242                            .justify_content(JustifyContent::CENTER)
243                            .background(th.surface_container_high)
244                            .clip_rounded(th.shapes.extra_large)
245                            .alpha(alpha)
246                            .scale(scale)
247                            .focus_group()
248                            .clickable()
249                            .focusable(false)
250                            .on_key_event({
251                                let s = state.clone();
252                                let props = props.clone();
253                                move |ke| {
254                                    use repose_core::input::{Key, KeyEventType};
255                                    if ke.key == Key::Escape && ke.event_type == KeyEventType::Down
256                                    {
257                                        let (dismiss, cb) = {
258                                            let p = props.borrow();
259                                            (p.dismiss_on_back_press, p.on_dismiss_request.clone())
260                                        };
261                                        if dismiss {
262                                            if let Some(cb) = cb {
263                                                cb();
264                                            } else {
265                                                s.dismiss();
266                                            }
267                                            return true;
268                                        }
269                                    }
270                                    false
271                                }
272                            }),
273                        platform_max_w,
274                        platform_max_h,
275                    );
276
277                    // Scroll when content exceeds the clamped height (dropdown pattern).
278                    let axis_binding = match scroll_state.to_binding() {
279                        repose_core::scroll::ScrollBinding::Vertical(a) => a,
280                        _ => unreachable!(),
281                    };
282                    let scrollable_body = Box(Modifier::new()
283                        .fill_max_width()
284                        .max_height(platform_max_h)
285                        .vertical_scroll(axis_binding))
286                    .child(content);
287
288                    let dialog = Box(dialog_mod).child(scrollable_body);
289
290                    let scrim_color = AlertDialogDefaults::scrim_color();
291                    let scrim_alpha = (scrim_color.3 as f32 / 255.0) * alpha;
292                    let scrim = Box(Modifier::new()
293                        .fill_max_size()
294                        .background(scrim_color.with_alpha_f32(scrim_alpha.clamp(0.0, 1.0)))
295                        .focusable(false)
296                        .input_blocker()
297                        .on_scroll(|_| Vec2::default())
298                        .on_click({
299                            let s = state.clone();
300                            let props = props.clone();
301                            move || {
302                                let (dismiss, cb) = {
303                                    let p = props.borrow();
304                                    (p.dismiss_on_click_outside, p.on_dismiss_request.clone())
305                                };
306                                if dismiss {
307                                    if let Some(cb) = cb {
308                                        cb();
309                                    } else {
310                                        s.dismiss();
311                                    }
312                                }
313                            }
314                        }));
315
316                    // Center inside the safe area (insets as padding), not the raw window.
317                    ZStack(Modifier::new().fill_max_size().absolute()).child((
318                        scrim,
319                        Box(Modifier::new()
320                            .fill_max_size()
321                            .padding_values(inset_pad)
322                            .justify_content(JustifyContent::CENTER)
323                            .align_items(AlignItems::CENTER)
324                            .hit_passthrough())
325                        .child(dialog),
326                    ))
327                }
328            });
329
330            let id = overlay.show_entry(builder, 900.0, false);
331            overlay_id.set(id);
332        }
333    } else {
334        let prev = overlay_id.get();
335        if prev != 0 {
336            let _ = overlay.dismiss(prev);
337            overlay_id.set(0);
338        }
339    }
340
341    Box(Modifier::new())
342}
343
344/// Configuration for alert dialog.
345#[derive(Clone, Debug)]
346pub struct AlertDialogConfig {
347    pub modifier: Modifier,
348    pub scrim_color: Color,
349    pub min_width: f32,
350    pub max_width: f32,
351    pub horizontal_padding: f32,
352    pub shape_radius: Option<f32>,
353    pub container_color: Color,
354    pub tonal_elevation: f32,
355}
356
357impl Default for AlertDialogConfig {
358    fn default() -> Self {
359        Self {
360            modifier: Modifier::new(),
361            scrim_color: AlertDialogDefaults::scrim_color(),
362            min_width: AlertDialogDefaults::MIN_WIDTH,
363            max_width: AlertDialogDefaults::MAX_WIDTH,
364            horizontal_padding: AlertDialogDefaults::HORIZONTAL_PADDING,
365            shape_radius: None,
366            container_color: theme().surface_container_high,
367            tonal_elevation: 0.0,
368        }
369    }
370}
371
372/// An improved AlertDialog using the overlay-based `Dialog`.
373///
374/// Shows a centered modal surface with title, text, confirm button, and optional
375/// dismiss button. Managed via a shared `DialogState`.
376pub fn AlertDialog(
377    state: Rc<DialogState>,
378    overlay: OverlayHandle,
379    title: View,
380    text: View,
381    confirm_button: View,
382    dismiss_button: Option<View>,
383    config: AlertDialogConfig,
384) -> View {
385    let content = Box(Modifier::new()
386        .background(config.container_color)
387        .clip_rounded(
388            config
389                .shape_radius
390                .unwrap_or_else(|| theme().shapes.extra_large),
391        ))
392    .child(super::alert_dialog_body(
393        title,
394        text,
395        confirm_button,
396        dismiss_button,
397    ));
398
399    Dialog(
400        state,
401        overlay,
402        Modifier::new()
403            .min_width(config.min_width)
404            .max_width(config.max_width)
405            .then(config.modifier),
406        DialogProperties::default(),
407        content,
408    )
409}
410
411/// Configuration for [`DatePickerDialog`].
412#[derive(Clone)]
413pub struct DatePickerDialogConfig {
414    pub modifier: Modifier,
415    pub shape_radius: Option<f32>,
416    pub colors: super::DatePickerColors,
417}
418
419impl Default for DatePickerDialogConfig {
420    fn default() -> Self {
421        Self {
422            modifier: Modifier::new(),
423            shape_radius: None,
424            colors: super::DatePickerColors::default(),
425        }
426    }
427}
428
429/// M3 Date Picker Dialog - wraps [`DatePicker`] inside a modal [`Dialog`]
430/// with confirm/cancel buttons. Equivalent to Compose's `DatePickerDialog`.
431///
432/// The `on_confirm` callback fires when a day is clicked or the OK button is pressed.
433/// The `on_dismiss` fires on Cancel or scrim tap.
434pub fn DatePickerDialog(
435    state: Rc<DialogState>,
436    overlay: OverlayHandle,
437    picker_state: Rc<DatePickerState>,
438    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
439    on_dismiss: Rc<dyn Fn()>,
440    config: DatePickerDialogConfig,
441) -> View {
442    let content = Box(Modifier::new()
443        .background(config.colors.container_color)
444        .clip_rounded(
445            config
446                .shape_radius
447                .unwrap_or_else(|| theme().shapes.extra_large),
448        ))
449    .child(Column(Modifier::new()).child((DatePicker(
450        picker_state.clone(),
451        on_confirm,
452        on_dismiss,
453        DatePickerConfig {
454            colors: config.colors,
455            ..DatePickerConfig::default()
456        },
457    ),)));
458
459    Dialog(
460        state,
461        overlay,
462        config.modifier,
463        DialogProperties::default(),
464        content,
465    )
466}
467
468/// Configuration for [`TimePickerDialog`].
469#[derive(Clone)]
470pub struct TimePickerDialogConfig {
471    pub modifier: Modifier,
472    pub shape_radius: Option<f32>,
473    pub container_color: Color,
474    pub colors: super::TimePickerColors,
475}
476
477impl Default for TimePickerDialogConfig {
478    fn default() -> Self {
479        Self {
480            modifier: Modifier::new(),
481            shape_radius: None,
482            container_color: theme().surface_container_high,
483            colors: super::TimePickerColors::default(),
484        }
485    }
486}
487
488/// M3 Time Picker Dialog - wraps [`TimePicker`] inside a modal [`Dialog`]
489/// with confirm/cancel buttons. Equivalent to Compose's `TimePickerDialog`.
490///
491/// The `on_confirm` callback fires when OK is pressed.
492/// The `on_dismiss` fires on Cancel or scrim tap.
493pub fn TimePickerDialog(
494    state: Rc<DialogState>,
495    overlay: OverlayHandle,
496    picker_state: Rc<TimePickerState>,
497    on_confirm: Rc<dyn Fn(u32, u32)>,
498    on_dismiss: Rc<dyn Fn()>,
499    config: TimePickerDialogConfig,
500) -> View {
501    let content = Box(Modifier::new()
502        .background(config.container_color)
503        .clip_rounded(
504            config
505                .shape_radius
506                .unwrap_or_else(|| theme().shapes.extra_large),
507        ))
508    .child(Column(Modifier::new()).child((TimePicker(
509        picker_state.clone(),
510        on_confirm,
511        on_dismiss,
512        TimePickerConfig {
513            colors: config.colors,
514            ..TimePickerConfig::default()
515        },
516    ),)));
517
518    Dialog(
519        state,
520        overlay,
521        config.modifier,
522        DialogProperties::default(),
523        content,
524    )
525}