Skip to main content

fret_ui_kit/primitives/
dialog.rs

1//! Dialog helpers (Radix `@radix-ui/react-dialog` outcomes).
2//!
3//! Upstream Dialog composes:
4//! - conditional mounting (`@radix-ui/react-presence`)
5//! - portal rendering (`@radix-ui/react-portal`)
6//! - dismissal + focus management (`@radix-ui/react-dismissable-layer`, `@radix-ui/react-focus-scope`)
7//! - modal scrolling + aria hiding (`react-remove-scroll`, `aria-hidden`)
8//!
9//! In Fret, these concerns map to:
10//! - presence: `crate::OverlayPresence` (driven by motion helpers in recipe layers)
11//! - portal + dismissal + focus restore/initial focus: per-window overlays (`crate::OverlayController`)
12//! - focus traversal scoping: modal barrier layers in `fret-ui` (ADR 0068)
13//!
14//! This module is intentionally thin: it provides Radix-named entry points for trigger a11y and
15//! modal overlay request wiring, without forcing a visual skin.
16
17use std::sync::Arc;
18
19use fret_runtime::Model;
20use fret_ui::action::{
21    DismissReason, DismissRequestCx, OnCloseAutoFocus, OnDismissRequest, OnOpenAutoFocus,
22};
23use fret_ui::element::{
24    AnyElement, ContainerProps, Elements, InsetStyle, LayoutStyle, Length, PositionStyle,
25    PressableProps, SizeStyle,
26};
27use fret_ui::elements::GlobalElementId;
28use fret_ui::{ElementContext, UiHost};
29
30use crate::declarative::ModelWatchExt;
31use crate::primitives::trigger_a11y;
32use crate::{IntoUiElement, OverlayController, OverlayPresence, OverlayRequest, collect_children};
33
34/// Policy for suppressing close auto-focus based on how a dialog overlay was dismissed.
35///
36/// This is primarily intended to prevent "focus stealing" when a close is triggered by a
37/// click-through outside interaction (e.g. non-modal dialog variants, or regressions where the
38/// modal barrier fails to block underlay focus).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct DialogCloseAutoFocusGuardPolicy {
41    /// Prevent close auto-focus when dismissed via an outside press.
42    pub prevent_on_outside_press: bool,
43    /// Prevent close auto-focus when dismissed due to focus moving outside the dismissible layer.
44    pub prevent_on_focus_outside: bool,
45}
46
47impl DialogCloseAutoFocusGuardPolicy {
48    /// Default policy for Radix-style dialogs.
49    ///
50    /// - Modal dialogs are not click-through, so outside presses generally should not suppress
51    ///   focus restoration.
52    /// - Focus-outside dismissals represent a real focus transfer, so restoring focus back to the
53    ///   trigger is usually undesirable.
54    pub fn for_modal(modal: bool) -> Self {
55        Self {
56            prevent_on_outside_press: !modal,
57            prevent_on_focus_outside: true,
58        }
59    }
60
61    /// Always prevent close auto-focus.
62    pub fn prevent_always() -> Self {
63        Self {
64            prevent_on_outside_press: true,
65            prevent_on_focus_outside: true,
66        }
67    }
68}
69
70/// Wrap `on_dismiss_request` to preserve default close behavior and install a close auto-focus
71/// guard that persists across frames.
72///
73/// Notes:
74/// - The returned dismiss handler applies Radix-like defaults: it closes the overlay unless the
75///   request is prevented.
76/// - The returned close hook runs the caller hook (if any) and then applies the guard policy
77///   unless the caller prevented default.
78pub fn dialog_close_auto_focus_guard_hooks<H: UiHost>(
79    cx: &mut ElementContext<'_, H>,
80    policy: DialogCloseAutoFocusGuardPolicy,
81    open: Model<bool>,
82    on_dismiss_request: Option<OnDismissRequest>,
83    on_close_auto_focus: Option<OnCloseAutoFocus>,
84) -> (Option<OnDismissRequest>, Option<OnCloseAutoFocus>) {
85    let should_install = policy.prevent_on_outside_press
86        || policy.prevent_on_focus_outside
87        || on_dismiss_request.is_some();
88    let should_install_close = policy.prevent_on_outside_press
89        || policy.prevent_on_focus_outside
90        || on_close_auto_focus.is_some();
91
92    if !should_install && !should_install_close {
93        return (on_dismiss_request, on_close_auto_focus);
94    }
95
96    let dismiss_reason = cx.local_model(|| None::<DismissReason>);
97
98    // Clear stale reasons when the overlay is open again (new session).
99    let open_now = cx.app.models().get_copied(&open).unwrap_or(false);
100    if open_now {
101        let _ = cx.app.models_mut().update(&dismiss_reason, |v| *v = None);
102    }
103
104    let dismiss_handler: Option<OnDismissRequest> = should_install.then(|| {
105        let user_dismiss_request = on_dismiss_request.clone();
106        let open_for_default_close = open.clone();
107        let dismiss_reason_for_hook = dismiss_reason.clone();
108        let handler: OnDismissRequest = Arc::new(move |host, cx, req| {
109            if let Some(user) = user_dismiss_request.as_ref() {
110                user(host, cx, req);
111            }
112
113            if !req.default_prevented() {
114                let should_store = match req.reason {
115                    DismissReason::OutsidePress { .. } => policy.prevent_on_outside_press,
116                    DismissReason::FocusOutside => policy.prevent_on_focus_outside,
117                    _ => false,
118                };
119                let _ = host.models_mut().update(&dismiss_reason_for_hook, |v| {
120                    *v = should_store.then_some(req.reason);
121                });
122                let _ = host
123                    .models_mut()
124                    .update(&open_for_default_close, |v| *v = false);
125            } else {
126                let _ = host
127                    .models_mut()
128                    .update(&dismiss_reason_for_hook, |v| *v = None);
129            }
130        });
131        handler
132    });
133
134    let on_close_auto_focus: Option<OnCloseAutoFocus> = should_install_close.then(|| {
135        let dismiss_reason_for_close = dismiss_reason.clone();
136        let user = on_close_auto_focus.clone();
137        let handler: OnCloseAutoFocus = Arc::new(move |host, cx, req| {
138            if let Some(user) = user.as_ref() {
139                user(host, cx, req);
140            }
141
142            let reason = host
143                .models_mut()
144                .read(&dismiss_reason_for_close, |v| *v)
145                .ok()
146                .flatten();
147            let _ = host
148                .models_mut()
149                .update(&dismiss_reason_for_close, |v| *v = None);
150
151            if req.default_prevented() {
152                return;
153            }
154
155            let should_prevent = match reason {
156                Some(DismissReason::OutsidePress { .. }) => policy.prevent_on_outside_press,
157                Some(DismissReason::FocusOutside) => policy.prevent_on_focus_outside,
158                _ => false,
159            };
160            if should_prevent {
161                req.prevent_default();
162            }
163        });
164        handler
165    });
166
167    (dismiss_handler.or(on_dismiss_request), on_close_auto_focus)
168}
169
170#[derive(Clone)]
171pub struct DialogOptions {
172    pub dismiss_on_overlay_press: bool,
173    pub initial_focus: Option<GlobalElementId>,
174    pub on_open_auto_focus: Option<OnOpenAutoFocus>,
175    pub on_close_auto_focus: Option<OnCloseAutoFocus>,
176}
177
178impl std::fmt::Debug for DialogOptions {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("DialogOptions")
181            .field("dismiss_on_overlay_press", &self.dismiss_on_overlay_press)
182            .field("initial_focus", &self.initial_focus)
183            .field("on_open_auto_focus", &self.on_open_auto_focus.is_some())
184            .field("on_close_auto_focus", &self.on_close_auto_focus.is_some())
185            .finish()
186    }
187}
188
189impl Default for DialogOptions {
190    fn default() -> Self {
191        Self {
192            dismiss_on_overlay_press: true,
193            initial_focus: None,
194            on_open_auto_focus: None,
195            on_close_auto_focus: None,
196        }
197    }
198}
199
200impl DialogOptions {
201    pub fn dismiss_on_overlay_press(mut self, dismiss_on_overlay_press: bool) -> Self {
202        self.dismiss_on_overlay_press = dismiss_on_overlay_press;
203        self
204    }
205
206    pub fn initial_focus(mut self, initial_focus: Option<GlobalElementId>) -> Self {
207        self.initial_focus = initial_focus;
208        self
209    }
210
211    pub fn on_open_auto_focus(mut self, hook: Option<OnOpenAutoFocus>) -> Self {
212        self.on_open_auto_focus = hook;
213        self
214    }
215
216    pub fn on_close_auto_focus(mut self, hook: Option<OnCloseAutoFocus>) -> Self {
217        self.on_close_auto_focus = hook;
218        self
219    }
220}
221
222/// Stable per-overlay root naming convention for dialog-like modal overlays.
223pub fn dialog_root_name(id: GlobalElementId) -> String {
224    OverlayController::modal_root_name(id)
225}
226
227/// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
228///
229/// This is a convenience helper for authoring Radix-shaped dialog roots:
230/// - if `controlled_open` is provided, it is used directly
231/// - otherwise an internal model is created (once) using `default_open` (Radix `defaultOpen`)
232pub fn dialog_use_open_model<H: UiHost>(
233    cx: &mut ElementContext<'_, H>,
234    controlled_open: Option<Model<bool>>,
235    default_open: impl FnOnce() -> bool,
236) -> crate::primitives::controllable_state::ControllableModel<bool> {
237    crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
238}
239
240/// A Radix-shaped `Dialog` root configuration surface.
241///
242/// Upstream supports a controlled/uncontrolled `open` state (`open` + `defaultOpen`). In Fret this
243/// maps to either:
244/// - a caller-provided `Model<bool>` (controlled), or
245/// - an internal `Model<bool>` stored in element state (uncontrolled).
246#[derive(Debug, Clone, Default)]
247pub struct DialogRoot {
248    open: Option<Model<bool>>,
249    default_open: bool,
250    options: DialogOptions,
251}
252
253impl DialogRoot {
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
259    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
260        self.open = open;
261        self
262    }
263
264    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
265    pub fn default_open(mut self, default_open: bool) -> Self {
266        self.default_open = default_open;
267        self
268    }
269
270    pub fn dismiss_on_overlay_press(mut self, dismiss_on_overlay_press: bool) -> Self {
271        self.options = self
272            .options
273            .dismiss_on_overlay_press(dismiss_on_overlay_press);
274        self
275    }
276
277    pub fn initial_focus(mut self, initial_focus: Option<GlobalElementId>) -> Self {
278        self.options = self.options.initial_focus(initial_focus);
279        self
280    }
281
282    pub fn options(&self) -> DialogOptions {
283        self.options.clone()
284    }
285
286    pub fn modal_request_with_dismiss_handler<H: UiHost, I, T>(
287        &self,
288        cx: &mut ElementContext<'_, H>,
289        id: GlobalElementId,
290        trigger: GlobalElementId,
291        presence: OverlayPresence,
292        on_dismiss_request: Option<OnDismissRequest>,
293        children: I,
294    ) -> OverlayRequest
295    where
296        I: IntoIterator<Item = T>,
297        T: IntoUiElement<H>,
298    {
299        let children = collect_children(cx, children);
300        modal_dialog_request_with_options_and_dismiss_handler(
301            id,
302            trigger,
303            self.open_model(cx),
304            presence,
305            self.options.clone(),
306            on_dismiss_request,
307            children,
308        )
309    }
310
311    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
312    pub fn use_open_model<H: UiHost>(
313        &self,
314        cx: &mut ElementContext<'_, H>,
315    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
316        dialog_use_open_model(cx, self.open.clone(), || self.default_open)
317    }
318
319    pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
320        self.use_open_model(cx).model()
321    }
322
323    /// Reads the current open value from the derived open model.
324    pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
325        let open_model = self.open_model(cx);
326        cx.watch_model(&open_model)
327            .layout()
328            .copied()
329            .unwrap_or(false)
330    }
331
332    pub fn modal_request<H: UiHost, I, T>(
333        &self,
334        cx: &mut ElementContext<'_, H>,
335        id: GlobalElementId,
336        trigger: GlobalElementId,
337        presence: OverlayPresence,
338        children: I,
339    ) -> OverlayRequest
340    where
341        I: IntoIterator<Item = T>,
342        T: IntoUiElement<H>,
343    {
344        let children = collect_children(cx, children);
345        modal_dialog_request_with_options(
346            id,
347            trigger,
348            self.open_model(cx),
349            presence,
350            self.options.clone(),
351            children,
352        )
353    }
354}
355
356/// Stamps Radix-like trigger semantics:
357/// - `expanded` mirrors `aria-expanded`
358/// - `controls_element` mirrors `aria-controls` (by element id).
359pub fn apply_dialog_trigger_a11y(
360    trigger: AnyElement,
361    expanded: bool,
362    content_element: Option<GlobalElementId>,
363) -> AnyElement {
364    trigger_a11y::apply_trigger_controls_expanded(trigger, Some(expanded), content_element)
365}
366
367/// Builds an overlay request for a Radix-style modal dialog.
368pub fn modal_dialog_request(
369    id: GlobalElementId,
370    trigger: GlobalElementId,
371    open: Model<bool>,
372    presence: OverlayPresence,
373    children: impl IntoIterator<Item = AnyElement>,
374) -> OverlayRequest {
375    modal_dialog_request_with_options(
376        id,
377        trigger,
378        open,
379        presence,
380        DialogOptions::default(),
381        children.into_iter().collect::<Vec<_>>(),
382    )
383}
384
385/// Builds an overlay request for a Radix-style modal dialog, with explicit options.
386pub fn modal_dialog_request_with_options(
387    id: GlobalElementId,
388    trigger: GlobalElementId,
389    open: Model<bool>,
390    presence: OverlayPresence,
391    options: DialogOptions,
392    children: impl IntoIterator<Item = AnyElement>,
393) -> OverlayRequest {
394    let children: Vec<AnyElement> = children.into_iter().collect();
395    let mut request = OverlayRequest::modal(id, Some(trigger), open, presence, children);
396    request.root_name = Some(dialog_root_name(id));
397    request.initial_focus = options.initial_focus;
398    request.on_open_auto_focus = options.on_open_auto_focus.clone();
399    request.on_close_auto_focus = options.on_close_auto_focus.clone();
400    request
401}
402
403/// Builds an overlay request for a Radix-style modal dialog, with a custom dismiss handler.
404///
405/// This mirrors the Radix `DismissableLayer` contract: callers may "prevent default" by not
406/// closing the `open` model in the handler.
407pub fn modal_dialog_request_with_options_and_dismiss_handler(
408    id: GlobalElementId,
409    trigger: GlobalElementId,
410    open: Model<bool>,
411    presence: OverlayPresence,
412    options: DialogOptions,
413    on_dismiss_request: Option<OnDismissRequest>,
414    children: impl IntoIterator<Item = AnyElement>,
415) -> OverlayRequest {
416    let mut request =
417        modal_dialog_request_with_options(id, trigger, open, presence, options, children);
418    request.dismissible_on_dismiss_request = on_dismiss_request;
419    request
420}
421
422/// Standard full-window modal barrier layout (absolute inset 0, fill).
423pub fn modal_barrier_layout() -> LayoutStyle {
424    LayoutStyle {
425        position: PositionStyle::Absolute,
426        inset: InsetStyle {
427            top: Some(fret_core::Px(0.0)).into(),
428            right: Some(fret_core::Px(0.0)).into(),
429            bottom: Some(fret_core::Px(0.0)).into(),
430            left: Some(fret_core::Px(0.0)).into(),
431        },
432        size: SizeStyle {
433            width: Length::Fill,
434            height: Length::Fill,
435            ..Default::default()
436        },
437        ..Default::default()
438    }
439}
440
441/// Builds a modal overlay barrier element that can optionally dismiss the given `open` model when
442/// pressed.
443///
444/// The barrier is intentionally skin-agnostic: pass any background/visual elements as `children`.
445pub fn modal_barrier<H: UiHost, I, T>(
446    cx: &mut ElementContext<'_, H>,
447    open: Model<bool>,
448    dismiss_on_press: bool,
449    children: I,
450) -> AnyElement
451where
452    I: IntoIterator<Item = T>,
453    T: IntoUiElement<H>,
454{
455    modal_barrier_with_dismiss_handler(cx, open, dismiss_on_press, None, children)
456}
457
458/// Builds a modal overlay barrier element that can optionally route dismissals through a custom
459/// dismiss handler.
460///
461/// When `on_dismiss_request` is provided and `dismiss_on_press` is enabled, barrier presses invoke
462/// the handler with `DismissReason::OutsidePress` and do not close `open` automatically.
463pub fn modal_barrier_with_dismiss_handler<H: UiHost, I, T>(
464    cx: &mut ElementContext<'_, H>,
465    open: Model<bool>,
466    dismiss_on_press: bool,
467    on_dismiss_request: Option<OnDismissRequest>,
468    children: I,
469) -> AnyElement
470where
471    I: IntoIterator<Item = T>,
472    T: IntoUiElement<H>,
473{
474    let layout = modal_barrier_layout();
475    let children = collect_children(cx, children);
476
477    if dismiss_on_press {
478        cx.pressable(
479            PressableProps {
480                layout,
481                enabled: true,
482                focusable: false,
483                ..Default::default()
484            },
485            move |cx, _st| {
486                if let Some(on_dismiss_request) = on_dismiss_request.clone() {
487                    let open_for_dismiss = open.clone();
488                    cx.pressable_add_on_pointer_up(Arc::new(move |host, action_cx, up| {
489                        let mut req = DismissRequestCx::new(DismissReason::OutsidePress {
490                            pointer: Some(fret_ui::action::OutsidePressCx {
491                                pointer_id: up.pointer_id,
492                                pointer_type: up.pointer_type,
493                                button: up.button,
494                                modifiers: up.modifiers,
495                                click_count: up.click_count,
496                            }),
497                        });
498                        on_dismiss_request(host, action_cx, &mut req);
499                        if !req.default_prevented() {
500                            let _ = host.models_mut().update(&open_for_dismiss, |v| *v = false);
501                        }
502                        fret_ui::action::PressablePointerUpResult::SkipActivate
503                    }));
504                } else {
505                    cx.pressable_add_on_pointer_up(Arc::new(move |host, _action_cx, _up| {
506                        let _ = host.models_mut().update(&open, |v| *v = false);
507                        fret_ui::action::PressablePointerUpResult::SkipActivate
508                    }));
509                }
510
511                children
512            },
513        )
514    } else {
515        cx.container(
516            ContainerProps {
517                layout,
518                ..Default::default()
519            },
520            move |_cx| children,
521        )
522    }
523}
524
525/// Convenience helper to assemble modal overlay children in a Radix-like order: barrier then
526/// content.
527pub fn modal_dialog_layer_elements<H: UiHost, I, T>(
528    cx: &mut ElementContext<'_, H>,
529    open: Model<bool>,
530    options: DialogOptions,
531    barrier_children: I,
532    content: AnyElement,
533) -> Elements
534where
535    I: IntoIterator<Item = T>,
536    T: IntoUiElement<H>,
537{
538    Elements::from([
539        modal_barrier(cx, open, options.dismiss_on_overlay_press, barrier_children),
540        content,
541    ])
542}
543
544/// Convenience helper to assemble modal overlay children in a Radix-like order (barrier then
545/// content), while routing barrier presses through an optional dismiss handler.
546pub fn modal_dialog_layer_elements_with_dismiss_handler<H: UiHost, I, T>(
547    cx: &mut ElementContext<'_, H>,
548    open: Model<bool>,
549    options: DialogOptions,
550    on_dismiss_request: Option<OnDismissRequest>,
551    barrier_children: I,
552    content: AnyElement,
553) -> Elements
554where
555    I: IntoIterator<Item = T>,
556    T: IntoUiElement<H>,
557{
558    Elements::from([
559        modal_barrier_with_dismiss_handler(
560            cx,
561            open,
562            options.dismiss_on_overlay_press,
563            on_dismiss_request,
564            barrier_children,
565        ),
566        content,
567    ])
568}
569
570/// Requests a Radix-style modal dialog overlay for the current window.
571pub fn request_modal_dialog<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
572    OverlayController::request(cx, request);
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    use fret_ui::action::DismissReason;
580    use std::sync::Arc;
581
582    use fret_app::App;
583    use fret_core::AppWindowId;
584    use fret_core::Event;
585    use fret_core::{PathCommand, SvgId, SvgService};
586    use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
587    use fret_core::{Point, Px, Rect, Size};
588    use fret_core::{TextBlobId, TextConstraints, TextInput, TextMetrics, TextService};
589    use fret_ui::UiTree;
590    use fret_ui::element::{ContainerProps, ElementKind, LayoutStyle, Length, PressableProps};
591    use fret_ui::elements::GlobalElementId;
592
593    #[derive(Default)]
594    struct FakeServices;
595
596    impl TextService for FakeServices {
597        fn prepare(
598            &mut self,
599            _input: &TextInput,
600            _constraints: TextConstraints,
601        ) -> (TextBlobId, TextMetrics) {
602            (
603                TextBlobId::default(),
604                TextMetrics {
605                    size: fret_core::Size::new(Px(0.0), Px(0.0)),
606                    baseline: Px(0.0),
607                },
608            )
609        }
610
611        fn release(&mut self, _blob: TextBlobId) {}
612    }
613
614    impl PathService for FakeServices {
615        fn prepare(
616            &mut self,
617            _commands: &[PathCommand],
618            _style: PathStyle,
619            _constraints: PathConstraints,
620        ) -> (PathId, PathMetrics) {
621            (PathId::default(), PathMetrics::default())
622        }
623
624        fn release(&mut self, _path: PathId) {}
625    }
626
627    impl SvgService for FakeServices {
628        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
629            SvgId::default()
630        }
631
632        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
633            true
634        }
635    }
636
637    impl fret_core::MaterialService for FakeServices {
638        fn register_material(
639            &mut self,
640            _desc: fret_core::MaterialDescriptor,
641        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
642            Err(fret_core::MaterialRegistrationError::Unsupported)
643        }
644
645        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
646            true
647        }
648    }
649
650    fn bounds() -> Rect {
651        Rect::new(
652            Point::new(Px(0.0), Px(0.0)),
653            Size::new(Px(200.0), Px(120.0)),
654        )
655    }
656
657    #[test]
658    fn dialog_root_open_model_uses_controlled_model() {
659        let window = AppWindowId::default();
660        let mut app = App::new();
661        let b = bounds();
662
663        let controlled = app.models_mut().insert(true);
664
665        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
666            let root = DialogRoot::new()
667                .open(Some(controlled.clone()))
668                .default_open(false);
669            assert_eq!(root.open_model(cx), controlled);
670        });
671    }
672
673    #[test]
674    fn dialog_root_options_builder_updates_options() {
675        let root = DialogRoot::new()
676            .dismiss_on_overlay_press(false)
677            .initial_focus(Some(GlobalElementId(0xbeef)));
678        let options = root.options();
679        assert!(!options.dismiss_on_overlay_press);
680        assert_eq!(options.initial_focus, Some(GlobalElementId(0xbeef)));
681    }
682
683    #[test]
684    fn modal_dialog_request_with_options_and_dismiss_handler_sets_dismiss_handler() {
685        let mut app = App::new();
686        let open = app.models_mut().insert(false);
687
688        let handler: OnDismissRequest = Arc::new(|_host, _cx, _req: &mut DismissRequestCx| {});
689        let req = modal_dialog_request_with_options_and_dismiss_handler(
690            GlobalElementId(0x123),
691            GlobalElementId(0x123),
692            open,
693            OverlayPresence::instant(true),
694            DialogOptions::default(),
695            Some(handler),
696            Vec::new(),
697        );
698
699        assert!(req.dismissible_on_dismiss_request.is_some());
700    }
701
702    #[test]
703    fn apply_dialog_trigger_a11y_sets_controls_and_expanded() {
704        let window = AppWindowId::default();
705        let mut app = App::new();
706        let b = bounds();
707
708        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
709            let trigger = cx.pressable(
710                PressableProps {
711                    layout: LayoutStyle::default(),
712                    enabled: true,
713                    focusable: true,
714                    ..Default::default()
715                },
716                |_cx, _st| Vec::new(),
717            );
718
719            let content = GlobalElementId(0xdead);
720            let trigger = apply_dialog_trigger_a11y(trigger, true, Some(content));
721
722            let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
723                panic!("expected pressable trigger");
724            };
725            assert_eq!(a11y.expanded, Some(true));
726            assert_eq!(a11y.controls_element, Some(content.0));
727        });
728    }
729
730    #[test]
731    fn modal_dialog_request_sets_default_root_name() {
732        let mut app = App::new();
733        let open = app.models_mut().insert(false);
734        let id = GlobalElementId(0x123);
735        let trigger = GlobalElementId(0x456);
736
737        let req = modal_dialog_request(
738            id,
739            trigger,
740            open,
741            OverlayPresence::instant(true),
742            Vec::new(),
743        );
744        let expected = dialog_root_name(id);
745        assert_eq!(req.root_name.as_deref(), Some(expected.as_str()));
746    }
747
748    #[test]
749    fn modal_dialog_request_with_options_sets_initial_focus() {
750        let mut app = App::new();
751        let open = app.models_mut().insert(false);
752        let id = GlobalElementId(0x123);
753        let trigger = GlobalElementId(0x456);
754        let initial_focus = GlobalElementId(0xbeef);
755
756        let opts = DialogOptions::default().initial_focus(Some(initial_focus));
757        let req = modal_dialog_request_with_options(
758            id,
759            trigger,
760            open,
761            OverlayPresence::instant(true),
762            opts,
763            Vec::new(),
764        );
765        assert_eq!(req.initial_focus, Some(initial_focus));
766    }
767
768    #[test]
769    fn modal_dialog_installs_barrier_root_for_semantics_snapshot() {
770        let window = AppWindowId::default();
771        let mut app = App::new();
772        let mut ui: UiTree<App> = UiTree::new();
773        ui.set_window(window);
774
775        let mut services = FakeServices::default();
776        let b = bounds();
777
778        OverlayController::begin_frame(&mut app, window);
779        let base = fret_ui::declarative::render_root(
780            &mut ui,
781            &mut app,
782            &mut services,
783            window,
784            b,
785            "base",
786            |_cx| Vec::new(),
787        );
788        ui.set_root(base);
789
790        let open = app.models_mut().insert(true);
791        let modal_id = GlobalElementId(0xabc);
792
793        let overlay_children =
794            fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
795                let content = cx.container(ContainerProps::default(), |_cx| Vec::new());
796                modal_dialog_layer_elements(
797                    cx,
798                    open.clone(),
799                    DialogOptions::default(),
800                    Vec::<AnyElement>::new(),
801                    content,
802                )
803            });
804
805        let req = modal_dialog_request(
806            modal_id,
807            modal_id,
808            open,
809            OverlayPresence::instant(true),
810            overlay_children,
811        );
812        OverlayController::request_for_window(&mut app, window, req);
813        OverlayController::render(&mut ui, &mut app, &mut services, window, b);
814
815        ui.request_semantics_snapshot();
816        ui.layout_all(&mut app, &mut services, b, 1.0);
817
818        let snap = ui.semantics_snapshot().expect("semantics snapshot");
819        let barrier_root = snap.barrier_root.expect("barrier_root");
820        assert!(
821            snap.roots
822                .iter()
823                .any(|r| r.root == barrier_root && r.blocks_underlay_input),
824            "expected barrier root to block underlay input"
825        );
826    }
827
828    #[test]
829    fn modal_barrier_can_dismiss_on_press() {
830        let window = AppWindowId::default();
831        let mut app = App::new();
832        let mut ui: UiTree<App> = UiTree::new();
833        ui.set_window(window);
834
835        let mut services = FakeServices::default();
836        let b = bounds();
837
838        OverlayController::begin_frame(&mut app, window);
839        let base = fret_ui::declarative::render_root(
840            &mut ui,
841            &mut app,
842            &mut services,
843            window,
844            b,
845            "base",
846            |_cx| Vec::new(),
847        );
848        ui.set_root(base);
849
850        let open = app.models_mut().insert(true);
851        let modal_id = GlobalElementId(0xabc);
852
853        let overlay_children =
854            fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
855                vec![modal_barrier(
856                    cx,
857                    open.clone(),
858                    true,
859                    Vec::<AnyElement>::new(),
860                )]
861            });
862
863        let req = modal_dialog_request(
864            modal_id,
865            modal_id,
866            open.clone(),
867            OverlayPresence::instant(true),
868            overlay_children,
869        );
870        OverlayController::request_for_window(&mut app, window, req);
871        OverlayController::render(&mut ui, &mut app, &mut services, window, b);
872        ui.layout_all(&mut app, &mut services, b, 1.0);
873
874        let modal_root = ui
875            .debug_layers_in_paint_order()
876            .into_iter()
877            .find(|l| l.blocks_underlay_input && l.visible)
878            .expect("modal layer root")
879            .root;
880        let barrier = ui.children(modal_root)[0];
881        let barrier_bounds = ui.debug_node_bounds(barrier).expect("barrier bounds");
882        assert!(
883            barrier_bounds.origin.x.0 <= 10.0
884                && barrier_bounds.origin.y.0 <= 10.0
885                && barrier_bounds.origin.x.0 + barrier_bounds.size.width.0 >= 10.0
886                && barrier_bounds.origin.y.0 + barrier_bounds.size.height.0 >= 10.0,
887            "expected modal barrier to cover (10, 10), got {barrier_bounds:?}"
888        );
889
890        ui.dispatch_event(
891            &mut app,
892            &mut services,
893            &Event::Pointer(fret_core::PointerEvent::Down {
894                position: Point::new(Px(10.0), Px(10.0)),
895                button: fret_core::MouseButton::Left,
896                modifiers: fret_core::Modifiers::default(),
897                click_count: 1,
898                pointer_id: fret_core::PointerId(0),
899                pointer_type: Default::default(),
900            }),
901        );
902        ui.dispatch_event(
903            &mut app,
904            &mut services,
905            &Event::Pointer(fret_core::PointerEvent::Up {
906                position: Point::new(Px(10.0), Px(10.0)),
907                button: fret_core::MouseButton::Left,
908                modifiers: fret_core::Modifiers::default(),
909                is_click: true,
910                click_count: 1,
911                pointer_id: fret_core::PointerId(0),
912                pointer_type: Default::default(),
913            }),
914        );
915
916        assert_eq!(app.models().get_copied(&open), Some(false));
917    }
918
919    #[test]
920    fn modal_barrier_can_route_dismissals_through_handler() {
921        let window = AppWindowId::default();
922        let mut app = App::new();
923        let mut ui: UiTree<App> = UiTree::new();
924        ui.set_window(window);
925
926        let mut services = FakeServices::default();
927        let b = bounds();
928
929        OverlayController::begin_frame(&mut app, window);
930        let base = fret_ui::declarative::render_root(
931            &mut ui,
932            &mut app,
933            &mut services,
934            window,
935            b,
936            "base",
937            |_cx| Vec::new(),
938        );
939        ui.set_root(base);
940
941        let open = app.models_mut().insert(true);
942        let modal_id = GlobalElementId(0xabc);
943
944        let reason_cell: Arc<std::sync::Mutex<Option<DismissReason>>> =
945            Arc::new(std::sync::Mutex::new(None));
946        let reason_cell_for_handler = reason_cell.clone();
947        let handler: OnDismissRequest = Arc::new(move |_host, _cx, req| {
948            *reason_cell_for_handler.lock().expect("reason lock") = Some(req.reason);
949            req.prevent_default();
950        });
951
952        let overlay_children =
953            fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
954                vec![modal_barrier_with_dismiss_handler(
955                    cx,
956                    open.clone(),
957                    true,
958                    Some(handler.clone()),
959                    Vec::<AnyElement>::new(),
960                )]
961            });
962
963        let req = modal_dialog_request(
964            modal_id,
965            modal_id,
966            open.clone(),
967            OverlayPresence::instant(true),
968            overlay_children,
969        );
970        OverlayController::request_for_window(&mut app, window, req);
971        OverlayController::render(&mut ui, &mut app, &mut services, window, b);
972        ui.layout_all(&mut app, &mut services, b, 1.0);
973
974        let modal_root = ui
975            .debug_layers_in_paint_order()
976            .into_iter()
977            .find(|l| l.blocks_underlay_input && l.visible)
978            .expect("modal layer root")
979            .root;
980        let barrier = ui.children(modal_root)[0];
981        let barrier_bounds = ui.debug_node_bounds(barrier).expect("barrier bounds");
982        assert!(
983            barrier_bounds.origin.x.0 <= 10.0
984                && barrier_bounds.origin.y.0 <= 10.0
985                && barrier_bounds.origin.x.0 + barrier_bounds.size.width.0 >= 10.0
986                && barrier_bounds.origin.y.0 + barrier_bounds.size.height.0 >= 10.0,
987            "expected modal barrier to cover (10, 10), got {barrier_bounds:?}"
988        );
989
990        ui.dispatch_event(
991            &mut app,
992            &mut services,
993            &Event::Pointer(fret_core::PointerEvent::Down {
994                position: Point::new(Px(10.0), Px(10.0)),
995                button: fret_core::MouseButton::Left,
996                modifiers: fret_core::Modifiers::default(),
997                click_count: 1,
998                pointer_id: fret_core::PointerId(0),
999                pointer_type: Default::default(),
1000            }),
1001        );
1002        ui.dispatch_event(
1003            &mut app,
1004            &mut services,
1005            &Event::Pointer(fret_core::PointerEvent::Up {
1006                position: Point::new(Px(10.0), Px(10.0)),
1007                button: fret_core::MouseButton::Left,
1008                modifiers: fret_core::Modifiers::default(),
1009                is_click: true,
1010                click_count: 1,
1011                pointer_id: fret_core::PointerId(0),
1012                pointer_type: Default::default(),
1013            }),
1014        );
1015
1016        assert_eq!(app.models().get_copied(&open), Some(true));
1017        let reason = *reason_cell.lock().expect("reason lock");
1018        let Some(DismissReason::OutsidePress { pointer }) = reason else {
1019            panic!("expected outside-press dismissal, got {reason:?}");
1020        };
1021        let Some(cx) = pointer else {
1022            panic!("expected pointer payload for outside-press dismissal");
1023        };
1024        assert_eq!(cx.pointer_id, fret_core::PointerId(0));
1025        assert_eq!(cx.pointer_type, fret_core::PointerType::Mouse);
1026        assert_eq!(cx.button, fret_core::MouseButton::Left);
1027        assert_eq!(cx.modifiers, fret_core::Modifiers::default());
1028        assert_eq!(cx.click_count, 1);
1029    }
1030
1031    #[test]
1032    fn modal_dialog_focuses_first_focusable_descendant_by_default() {
1033        let window = AppWindowId::default();
1034        let mut app = App::new();
1035        let mut ui: UiTree<App> = UiTree::new();
1036        ui.set_window(window);
1037
1038        let mut services = FakeServices::default();
1039        let b = bounds();
1040
1041        OverlayController::begin_frame(&mut app, window);
1042        let base = fret_ui::declarative::render_root(
1043            &mut ui,
1044            &mut app,
1045            &mut services,
1046            window,
1047            b,
1048            "base",
1049            |_cx| Vec::new(),
1050        );
1051        ui.set_root(base);
1052
1053        let open = app.models_mut().insert(true);
1054        let modal_id = GlobalElementId(0xabc);
1055
1056        let mut focusable_element: Option<GlobalElementId> = None;
1057        let overlay_children =
1058            fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
1059                let content = cx.pressable_with_id(
1060                    PressableProps {
1061                        layout: {
1062                            let mut layout = LayoutStyle::default();
1063                            layout.size.width = Length::Px(Px(80.0));
1064                            layout.size.height = Length::Px(Px(32.0));
1065                            layout
1066                        },
1067                        enabled: true,
1068                        focusable: true,
1069                        ..Default::default()
1070                    },
1071                    |_cx, _st, id| {
1072                        focusable_element = Some(id);
1073                        Vec::new()
1074                    },
1075                );
1076
1077                modal_dialog_layer_elements(
1078                    cx,
1079                    open.clone(),
1080                    DialogOptions::default(),
1081                    Vec::<AnyElement>::new(),
1082                    content,
1083                )
1084            });
1085        let focusable_element = focusable_element.expect("focusable element id");
1086
1087        let req = modal_dialog_request(
1088            modal_id,
1089            modal_id,
1090            open,
1091            OverlayPresence::instant(true),
1092            overlay_children,
1093        );
1094        OverlayController::request_for_window(&mut app, window, req);
1095        OverlayController::render(&mut ui, &mut app, &mut services, window, b);
1096        ui.layout_all(&mut app, &mut services, b, 1.0);
1097
1098        let focused = ui.focus();
1099        let expected = fret_ui::elements::node_for_element(&mut app, window, focusable_element);
1100        assert_eq!(focused, expected);
1101    }
1102}