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}
69
70impl Default for DialogProperties {
71    fn default() -> Self {
72        Self {
73            on_dismiss_request: None,
74            dismiss_on_click_outside: true,
75            dismiss_on_back_press: true,
76        }
77    }
78}
79
80/// A modal dialog rendered in the overlay layer with scrim and spring animation.
81///
82/// Unlike the inline `AlertDialog`, this version renders outside the layout tree
83/// so it is never clipped by parent containers, scroll areas, or stacks.
84///
85/// Caller should create a `DialogState` and manage visibility via `show()`/`dismiss()`.
86///
87/// Focus behavior: dialog content is wrapped in a focus group, so Tab/Shift+Tab
88/// cycles within the dialog instead of moving to background elements.
89///
90/// Escape handling: when the dialog content is focused and `dismiss_on_back_press`
91/// is true, pressing Escape calls `on_dismiss_request` (or `state.dismiss()` if
92/// no `on_dismiss_request` is set). Set `dismiss_on_back_press = false` or pass
93/// `on_dismiss_request = Some(Rc::new(|| {}))` to prevent Escape from closing.
94pub fn Dialog(
95    state: Rc<DialogState>,
96    overlay: OverlayHandle,
97    modifier: Modifier,
98    properties: DialogProperties,
99    content: View,
100) -> View {
101    let overlay_id = remember_with_key(state.key("oid"), || signal(0u64));
102
103    // RefCell holding the latest content so the overlay builder reads fresh state each frame
104    let current_content = remember_state_with_key(state.key("c"), || Box(Modifier::new()));
105    *current_content.borrow_mut() = content;
106
107    // Store properties so the overlay closure reads fresh values each frame
108    let props = remember_state_with_key(state.key("p"), || properties.clone());
109    *props.borrow_mut() = properties;
110
111    // Animated scale/alpha for enter/exit
112    let spec = AnimationSpec::tween(Duration::from_millis(200), Easing::FastOutSlowIn);
113    let anim = remember_state_with_key(state.key("anim"), || AnimatedValue::new(0.0, spec));
114    let last_target = remember_state_with_key(state.key("atarget"), || f32::NAN);
115    let anim_target = if state.is_visible() { 1.0 } else { 0.0 };
116
117    {
118        let mut a = anim.borrow_mut();
119        let mut lt = last_target.borrow_mut();
120        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
121            a.set_spec(spec);
122            a.set_target(anim_target);
123            *lt = anim_target;
124        }
125        drop(lt);
126        if a.update() {
127            request_frame();
128        }
129    }
130
131    let progress = *anim.borrow().get();
132    let visible = state.is_visible() || progress > 0.01;
133
134    if visible {
135        if overlay_id.get() == 0 {
136            let builder: Rc<dyn Fn() -> View> = Rc::new({
137                let state = state.clone();
138                let anim = anim.clone();
139                let modifier = modifier.clone();
140                let current_content = current_content.clone();
141                let props = props.clone();
142                move || {
143                    let progress = *anim.borrow().get();
144                    let alpha = progress.min(1.0);
145                    let scale = 0.8 + 0.2 * progress;
146                    let th = theme();
147                    let content = current_content.borrow().clone();
148                    let _p = props.borrow().clone();
149
150                    // Dialog surface with focus group for tab isolation
151                    let dialog = Box(Modifier::new()
152                        .min_width(280.0)
153                        .max_width(560.0)
154                        .then(modifier.clone())
155                        .justify_content(JustifyContent::CENTER)
156                        .background(th.surface_container_high)
157                        .clip_rounded(th.shapes.extra_large)
158                        .alpha(alpha)
159                        .scale(scale)
160                        .focus_group()
161                        .clickable()
162                        .focusable(false)
163                        .on_key_event({
164                            let s = state.clone();
165                            let p = props.clone();
166                            move |ke| {
167                                use repose_core::input::{Key, KeyEventType};
168                                if ke.key == Key::Escape && ke.event_type == KeyEventType::Down {
169                                    let (dismiss, cb) = {
170                                        let p = p.borrow();
171                                        (p.dismiss_on_back_press, p.on_dismiss_request.clone())
172                                    };
173                                    if dismiss {
174                                        if let Some(cb) = cb {
175                                            cb();
176                                        } else {
177                                            s.dismiss();
178                                        }
179                                        return true;
180                                    }
181                                }
182                                false
183                            }
184                        }))
185                    .child(content);
186
187                    let scrim_color = AlertDialogDefaults::scrim_color();
188                    let scrim_alpha = (scrim_color.3 as f32 / 255.0) * alpha;
189                    let scrim = Box(Modifier::new()
190                        .fill_max_size()
191                        .background(scrim_color.with_alpha_f32(scrim_alpha.clamp(0.0, 1.0)))
192                        .focusable(false)
193                        .input_blocker()
194                        .on_scroll(|_| Vec2::default())
195                        .on_click({
196                            let s = state.clone();
197                            let p = props.clone();
198                            move || {
199                                let (dismiss, cb) = {
200                                    let p = p.borrow();
201                                    (p.dismiss_on_click_outside, p.on_dismiss_request.clone())
202                                };
203                                if dismiss {
204                                    if let Some(cb) = cb {
205                                        cb();
206                                    } else {
207                                        s.dismiss();
208                                    }
209                                }
210                            }
211                        }));
212
213                    ZStack(Modifier::new().fill_max_size().absolute()).child((
214                        scrim,
215                        Box(Modifier::new()
216                            .fill_max_size()
217                            .justify_content(JustifyContent::CENTER)
218                            .align_items(AlignItems::CENTER)
219                            .hit_passthrough())
220                        .child(dialog),
221                    ))
222                }
223            });
224
225            let id = overlay.show_entry(builder, 900.0, false);
226            overlay_id.set(id);
227        }
228    } else {
229        let prev = overlay_id.get();
230        if prev != 0 {
231            let _ = overlay.dismiss(prev);
232            overlay_id.set(0);
233        }
234    }
235
236    Box(Modifier::new())
237}
238
239/// Configuration for alert dialog.
240#[derive(Clone, Debug)]
241pub struct AlertDialogConfig {
242    pub modifier: Modifier,
243    pub scrim_color: Color,
244    pub min_width: f32,
245    pub max_width: f32,
246    pub horizontal_padding: f32,
247    pub shape_radius: Option<f32>,
248    pub container_color: Color,
249    pub tonal_elevation: f32,
250}
251
252impl Default for AlertDialogConfig {
253    fn default() -> Self {
254        Self {
255            modifier: Modifier::new(),
256            scrim_color: AlertDialogDefaults::scrim_color(),
257            min_width: AlertDialogDefaults::MIN_WIDTH,
258            max_width: AlertDialogDefaults::MAX_WIDTH,
259            horizontal_padding: AlertDialogDefaults::HORIZONTAL_PADDING,
260            shape_radius: None,
261            container_color: theme().surface_container_high,
262            tonal_elevation: 0.0,
263        }
264    }
265}
266
267/// An improved AlertDialog using the overlay-based `Dialog`.
268///
269/// Shows a centered modal surface with title, text, confirm button, and optional
270/// dismiss button. Managed via a shared `DialogState`.
271pub fn AlertDialog(
272    state: Rc<DialogState>,
273    overlay: OverlayHandle,
274    title: View,
275    text: View,
276    confirm_button: View,
277    dismiss_button: Option<View>,
278    config: AlertDialogConfig,
279) -> View {
280    let content = Box(Modifier::new()
281        .background(config.container_color)
282        .clip_rounded(
283            config
284                .shape_radius
285                .unwrap_or_else(|| theme().shapes.extra_large),
286        ))
287    .child(super::alert_dialog_body(
288        title,
289        text,
290        confirm_button,
291        dismiss_button,
292    ));
293
294    Dialog(
295        state,
296        overlay,
297        Modifier::new()
298            .min_width(config.min_width)
299            .max_width(config.max_width)
300            .then(config.modifier),
301        DialogProperties::default(),
302        content,
303    )
304}
305
306/// Configuration for [`DatePickerDialog`].
307#[derive(Clone)]
308pub struct DatePickerDialogConfig {
309    pub modifier: Modifier,
310    pub shape_radius: Option<f32>,
311    pub colors: super::DatePickerColors,
312}
313
314impl Default for DatePickerDialogConfig {
315    fn default() -> Self {
316        Self {
317            modifier: Modifier::new(),
318            shape_radius: None,
319            colors: super::DatePickerColors::default(),
320        }
321    }
322}
323
324/// M3 Date Picker Dialog - wraps [`DatePicker`] inside a modal [`Dialog`]
325/// with confirm/cancel buttons. Equivalent to Compose's `DatePickerDialog`.
326///
327/// The `on_confirm` callback fires when a day is clicked or the OK button is pressed.
328/// The `on_dismiss` fires on Cancel or scrim tap.
329pub fn DatePickerDialog(
330    state: Rc<DialogState>,
331    overlay: OverlayHandle,
332    picker_state: Rc<DatePickerState>,
333    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
334    on_dismiss: Rc<dyn Fn()>,
335    config: DatePickerDialogConfig,
336) -> View {
337    let content = Box(Modifier::new()
338        .background(config.colors.container_color)
339        .clip_rounded(
340            config
341                .shape_radius
342                .unwrap_or_else(|| theme().shapes.extra_large),
343        ))
344    .child(Column(Modifier::new()).child((DatePicker(
345        picker_state.clone(),
346        on_confirm,
347        on_dismiss,
348        DatePickerConfig {
349            colors: config.colors,
350            ..DatePickerConfig::default()
351        },
352    ),)));
353
354    Dialog(
355        state,
356        overlay,
357        config.modifier,
358        DialogProperties::default(),
359        content,
360    )
361}
362
363/// Configuration for [`TimePickerDialog`].
364#[derive(Clone)]
365pub struct TimePickerDialogConfig {
366    pub modifier: Modifier,
367    pub shape_radius: Option<f32>,
368    pub container_color: Color,
369    pub colors: super::TimePickerColors,
370}
371
372impl Default for TimePickerDialogConfig {
373    fn default() -> Self {
374        Self {
375            modifier: Modifier::new(),
376            shape_radius: None,
377            container_color: theme().surface_container_high,
378            colors: super::TimePickerColors::default(),
379        }
380    }
381}
382
383/// M3 Time Picker Dialog - wraps [`TimePicker`] inside a modal [`Dialog`]
384/// with confirm/cancel buttons. Equivalent to Compose's `TimePickerDialog`.
385///
386/// The `on_confirm` callback fires when OK is pressed.
387/// The `on_dismiss` fires on Cancel or scrim tap.
388pub fn TimePickerDialog(
389    state: Rc<DialogState>,
390    overlay: OverlayHandle,
391    picker_state: Rc<TimePickerState>,
392    on_confirm: Rc<dyn Fn(u32, u32)>,
393    on_dismiss: Rc<dyn Fn()>,
394    config: TimePickerDialogConfig,
395) -> View {
396    let content = Box(Modifier::new()
397        .background(config.container_color)
398        .clip_rounded(
399            config
400                .shape_radius
401                .unwrap_or_else(|| theme().shapes.extra_large),
402        ))
403    .child(Column(Modifier::new()).child((TimePicker(
404        picker_state.clone(),
405        on_confirm,
406        on_dismiss,
407        TimePickerConfig {
408            colors: config.colors,
409            ..TimePickerConfig::default()
410        },
411    ),)));
412
413    Dialog(
414        state,
415        overlay,
416        config.modifier,
417        DialogProperties::default(),
418        content,
419    )
420}