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