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, Row, Spacer, Text, ViewExt, ZStack};
9use web_time::Duration;
10
11use super::{AlertDialogDefaults, Button, ButtonConfig, TextButton};
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 th = theme();
146                    let content = current_content.borrow().clone();
147                    let p = props.borrow().clone();
148
149                    // Dialog surface with focus group for tab isolation
150                    let dialog = Box(Modifier::new()
151                        .min_width(280.0)
152                        .max_width(560.0)
153                        .then(modifier.clone())
154                        .justify_content(JustifyContent::Center)
155                        .background(th.surface_container_high)
156                        .clip_rounded(th.shapes.extra_large)
157                        .alpha(alpha)
158                        .focus_group()
159                        .on_key_event({
160                            let s = state.clone();
161                            let p = props.clone();
162                            move |ke| {
163                                use repose_core::input::{Key, KeyEventType};
164                                if ke.key == Key::Escape && ke.event_type == KeyEventType::Down {
165                                    let (dismiss, cb) = {
166                                        let p = p.borrow();
167                                        (p.dismiss_on_back_press, p.on_dismiss_request.clone())
168                                    };
169                                    if dismiss {
170                                        if let Some(cb) = cb {
171                                            cb();
172                                        } else {
173                                            s.dismiss();
174                                        }
175                                        return true;
176                                    }
177                                }
178                                false
179                            }
180                        }))
181                    .child(content);
182
183                    // Scrim that dismisses on click (if enabled)
184                    let scrim = Box(Modifier::new()
185                        .fill_max_size()
186                        .background(th.scrim.with_alpha((85.0 * alpha) as u8))
187                        .focusable(false)
188                        .on_click({
189                            let s = state.clone();
190                            let p = props.clone();
191                            move || {
192                                let (dismiss, cb) = {
193                                    let p = p.borrow();
194                                    (p.dismiss_on_click_outside, p.on_dismiss_request.clone())
195                                };
196                                if dismiss {
197                                    if let Some(cb) = cb {
198                                        cb();
199                                    } else {
200                                        s.dismiss();
201                                    }
202                                }
203                            }
204                        }));
205
206                    ZStack(Modifier::new().fill_max_size().absolute()).child((
207                        scrim,
208                        Box(Modifier::new()
209                            .fill_max_size()
210                            .justify_content(JustifyContent::Center)
211                            .align_items(AlignItems::Center)
212                            .hit_passthrough())
213                        .child(dialog),
214                    ))
215                }
216            });
217
218            let id = overlay.show_entry(builder, 900.0, false);
219            overlay_id.set(id);
220        }
221    } else {
222        let prev = overlay_id.get();
223        if prev != 0 {
224            let _ = overlay.dismiss(prev);
225            overlay_id.set(0);
226        }
227    }
228
229    Box(Modifier::new())
230}
231
232/// Configuration for alert dialog.
233#[derive(Clone, Debug)]
234pub struct AlertDialogConfig {
235    pub modifier: Modifier,
236    pub scrim_color: Color,
237    pub min_width: f32,
238    pub max_width: f32,
239    pub horizontal_padding: f32,
240    pub shape_radius: Option<f32>,
241    pub container_color: Color,
242    pub tonal_elevation: f32,
243}
244
245impl Default for AlertDialogConfig {
246    fn default() -> Self {
247        Self {
248            modifier: Modifier::new(),
249            scrim_color: AlertDialogDefaults::scrim_color(),
250            min_width: AlertDialogDefaults::MIN_WIDTH,
251            max_width: AlertDialogDefaults::MAX_WIDTH,
252            horizontal_padding: AlertDialogDefaults::HORIZONTAL_PADDING,
253            shape_radius: None,
254            container_color: theme().surface_container_high,
255            tonal_elevation: 0.0,
256        }
257    }
258}
259
260/// An improved AlertDialog using the overlay-based `Dialog`.
261///
262/// Shows a centered modal surface with title, text, confirm button, and optional
263/// dismiss button. Managed via a shared `DialogState`.
264pub fn AlertDialog(
265    state: Rc<DialogState>,
266    overlay: OverlayHandle,
267    title: View,
268    text: View,
269    confirm_button: View,
270    dismiss_button: Option<View>,
271    config: AlertDialogConfig,
272) -> View {
273    let content = Box(Modifier::new()
274        .background(config.container_color)
275        .clip_rounded(
276            config
277                .shape_radius
278                .unwrap_or_else(|| theme().shapes.extra_large),
279        ))
280    .child(super::alert_dialog_body(
281        title,
282        text,
283        confirm_button,
284        dismiss_button,
285    ));
286
287    Dialog(
288        state,
289        overlay,
290        Modifier::new()
291            .min_width(config.min_width)
292            .max_width(config.max_width)
293            .then(config.modifier),
294        DialogProperties::default(),
295        content,
296    )
297}
298
299/// Configuration for [`DatePickerDialog`].
300#[derive(Clone)]
301pub struct DatePickerDialogConfig {
302    pub modifier: Modifier,
303    pub shape_radius: Option<f32>,
304    pub colors: super::DatePickerColors,
305}
306
307impl Default for DatePickerDialogConfig {
308    fn default() -> Self {
309        Self {
310            modifier: Modifier::new(),
311            shape_radius: None,
312            colors: super::DatePickerColors::default(),
313        }
314    }
315}
316
317/// M3 Date Picker Dialog - wraps [`DatePicker`] inside a modal [`Dialog`]
318/// with confirm/cancel buttons. Equivalent to Compose's `DatePickerDialog`.
319///
320/// The `on_confirm` callback fires when a day is clicked or the OK button is pressed.
321/// The `on_dismiss` fires on Cancel or scrim tap.
322pub fn DatePickerDialog(
323    state: Rc<DialogState>,
324    overlay: OverlayHandle,
325    picker_state: Rc<DatePickerState>,
326    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
327    on_dismiss: Rc<dyn Fn()>,
328    config: DatePickerDialogConfig,
329) -> View {
330    let content = Box(Modifier::new()
331        .background(config.colors.container_color)
332        .clip_rounded(
333            config
334                .shape_radius
335                .unwrap_or_else(|| theme().shapes.extra_large),
336        ))
337    .child(Column(Modifier::new()).child((DatePicker(
338        picker_state.clone(),
339        on_confirm,
340        on_dismiss,
341        DatePickerConfig {
342            colors: config.colors,
343            ..DatePickerConfig::default()
344        },
345    ),)));
346
347    Dialog(
348        state,
349        overlay,
350        config.modifier,
351        DialogProperties::default(),
352        content,
353    )
354}
355
356/// Configuration for [`TimePickerDialog`].
357#[derive(Clone)]
358pub struct TimePickerDialogConfig {
359    pub modifier: Modifier,
360    pub shape_radius: Option<f32>,
361    pub container_color: Color,
362    pub colors: super::TimePickerColors,
363}
364
365impl Default for TimePickerDialogConfig {
366    fn default() -> Self {
367        Self {
368            modifier: Modifier::new(),
369            shape_radius: None,
370            container_color: theme().surface_container_high,
371            colors: super::TimePickerColors::default(),
372        }
373    }
374}
375
376/// M3 Time Picker Dialog - wraps [`TimePicker`] inside a modal [`Dialog`]
377/// with confirm/cancel buttons. Equivalent to Compose's `TimePickerDialog`.
378///
379/// The `on_confirm` callback fires when OK is pressed.
380/// The `on_dismiss` fires on Cancel or scrim tap.
381pub fn TimePickerDialog(
382    state: Rc<DialogState>,
383    overlay: OverlayHandle,
384    picker_state: Rc<TimePickerState>,
385    on_confirm: Rc<dyn Fn(u32, u32)>,
386    on_dismiss: Rc<dyn Fn()>,
387    config: TimePickerDialogConfig,
388) -> View {
389    let content = Box(Modifier::new()
390        .background(config.container_color)
391        .clip_rounded(
392            config
393                .shape_radius
394                .unwrap_or_else(|| theme().shapes.extra_large),
395        ))
396    .child(Column(Modifier::new()).child((TimePicker(
397        picker_state.clone(),
398        on_confirm,
399        on_dismiss,
400        TimePickerConfig {
401            colors: config.colors,
402            ..TimePickerConfig::default()
403        },
404    ),)));
405
406    Dialog(
407        state,
408        overlay,
409        config.modifier,
410        DialogProperties::default(),
411        content,
412    )
413}