Skip to main content

fret_ui_kit/primitives/
alert_dialog.rs

1//! Alert dialog helpers (Radix `@radix-ui/react-alert-dialog` outcomes).
2//!
3//! Upstream AlertDialog is a constrained Dialog variant:
4//! - always modal,
5//! - prevents outside interactions from dismissing,
6//! - prefers focusing the `Cancel` action on open.
7//!
8//! In Fret, modal dismissal via outside press is modeled at the recipe layer (e.g. the overlay
9//! barrier click handler). This module focuses on the Radix-specific focus preference: choosing
10//! the cancel action as the default initial focus target when present.
11//!
12//! For parity with Radix `FocusScope`, alert dialogs also allow customizing open/close auto focus
13//! via `AlertDialogOptions` (forwarded into `DialogOptions`).
14
15use std::collections::HashMap;
16
17use fret_runtime::Model;
18use fret_runtime::ModelId;
19use fret_ui::action::{OnCloseAutoFocus, OnOpenAutoFocus};
20use fret_ui::element::{AnyElement, Elements, LayoutStyle};
21use fret_ui::elements::GlobalElementId;
22use fret_ui::{ElementContext, UiHost};
23
24use crate::declarative::ModelWatchExt;
25use crate::primitives::dialog as dialog_prim;
26use crate::primitives::dialog::DialogOptions;
27use crate::primitives::trigger_a11y;
28use crate::{IntoUiElement, OverlayPresence, OverlayRequest, collect_children};
29
30/// Stable per-overlay root naming convention for alert dialogs.
31pub fn alert_dialog_root_name(id: GlobalElementId) -> String {
32    dialog_prim::dialog_root_name(id)
33}
34
35/// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
36///
37/// AlertDialog itself is a constrained Dialog variant. This helper exists to standardize how
38/// recipes derive the open model (`open` / `defaultOpen`) before applying alert-dialog-specific
39/// focus preferences.
40pub fn alert_dialog_use_open_model<H: UiHost>(
41    cx: &mut ElementContext<'_, H>,
42    controlled_open: Option<Model<bool>>,
43    default_open: impl FnOnce() -> bool,
44) -> crate::primitives::controllable_state::ControllableModel<bool> {
45    crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
46}
47
48/// A Radix-shaped `AlertDialog` root configuration surface.
49///
50/// Upstream AlertDialog is a constrained Dialog variant: always modal, and prefers focusing the
51/// cancel action by default. This root helper standardizes:
52/// - controlled/uncontrolled open modeling (`open` / `defaultOpen`)
53/// - initial focus preference (`PreferCancel` by default)
54#[derive(Debug, Clone, Default)]
55pub struct AlertDialogRoot {
56    open: Option<Model<bool>>,
57    default_open: bool,
58    options: AlertDialogOptions,
59}
60
61impl AlertDialogRoot {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
67    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
68        self.open = open;
69        self
70    }
71
72    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
73    pub fn default_open(mut self, default_open: bool) -> Self {
74        self.default_open = default_open;
75        self
76    }
77
78    pub fn initial_focus(mut self, initial_focus: AlertDialogInitialFocus) -> Self {
79        self.options = self.options.initial_focus(initial_focus);
80        self
81    }
82
83    pub fn options(&self) -> AlertDialogOptions {
84        self.options.clone()
85    }
86
87    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
88    pub fn use_open_model<H: UiHost>(
89        &self,
90        cx: &mut ElementContext<'_, H>,
91    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
92        alert_dialog_use_open_model(cx, self.open.clone(), || self.default_open)
93    }
94
95    pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
96        self.use_open_model(cx).model()
97    }
98
99    pub fn open_id<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> ModelId {
100        self.open_model(cx).id()
101    }
102
103    pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
104        let open_model = self.open_model(cx);
105        cx.watch_model(&open_model)
106            .layout()
107            .copied()
108            .unwrap_or(false)
109    }
110
111    pub fn dialog_options<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> DialogOptions {
112        let open_id = self.open_id(cx);
113        dialog_options_for_alert_dialog(cx, open_id, self.options.clone())
114    }
115
116    pub fn modal_request<H: UiHost, I, T>(
117        &self,
118        cx: &mut ElementContext<'_, H>,
119        id: GlobalElementId,
120        trigger: GlobalElementId,
121        presence: OverlayPresence,
122        children: I,
123    ) -> OverlayRequest
124    where
125        I: IntoIterator<Item = T>,
126        T: IntoUiElement<H>,
127    {
128        let open = self.open_model(cx);
129        let options = self.dialog_options(cx);
130
131        dialog_prim::modal_dialog_request_with_options(
132            id,
133            trigger,
134            open,
135            presence,
136            options,
137            collect_children(cx, children),
138        )
139    }
140}
141
142#[derive(Default)]
143struct AlertDialogCancelRegistry {
144    by_open: HashMap<ModelId, GlobalElementId>,
145}
146
147/// Records a `Cancel` element for the given open model id.
148///
149/// This is a best-effort mechanism: callers should re-register on each frame while the alert
150/// dialog is open so stale entries are naturally overwritten.
151pub fn register_cancel_for_open_model<H: UiHost>(
152    cx: &mut ElementContext<'_, H>,
153    open_id: ModelId,
154    element: GlobalElementId,
155) {
156    cx.app
157        .with_global_mut_untracked(AlertDialogCancelRegistry::default, |reg, _app| {
158            reg.by_open.entry(open_id).or_insert(element);
159        });
160}
161
162/// Clears the stored cancel element for the given open model id.
163pub fn clear_cancel_for_open_model<H: UiHost>(cx: &mut ElementContext<'_, H>, open_id: ModelId) {
164    cx.app
165        .with_global_mut_untracked(AlertDialogCancelRegistry::default, |reg, _app| {
166            reg.by_open.remove(&open_id);
167        });
168}
169
170/// Returns the preferred initial focus element for this alert dialog (the registered cancel
171/// action), if any.
172pub fn cancel_element_for_open_model<H: UiHost>(
173    cx: &mut ElementContext<'_, H>,
174    open_id: ModelId,
175) -> Option<GlobalElementId> {
176    cx.app
177        .with_global_mut_untracked(AlertDialogCancelRegistry::default, |reg, _app| {
178            reg.by_open.get(&open_id).copied()
179        })
180}
181
182#[derive(Debug, Clone, Copy, Default)]
183pub enum AlertDialogInitialFocus {
184    None,
185    #[default]
186    PreferCancel,
187    Element(GlobalElementId),
188}
189
190#[derive(Clone, Default)]
191pub struct AlertDialogOptions {
192    pub initial_focus: AlertDialogInitialFocus,
193    pub on_open_auto_focus: Option<OnOpenAutoFocus>,
194    pub on_close_auto_focus: Option<OnCloseAutoFocus>,
195}
196
197impl std::fmt::Debug for AlertDialogOptions {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("AlertDialogOptions")
200            .field("initial_focus", &self.initial_focus)
201            .field("on_open_auto_focus", &self.on_open_auto_focus.is_some())
202            .field("on_close_auto_focus", &self.on_close_auto_focus.is_some())
203            .finish()
204    }
205}
206
207impl AlertDialogOptions {
208    pub fn initial_focus(mut self, initial_focus: AlertDialogInitialFocus) -> Self {
209        self.initial_focus = initial_focus;
210        self
211    }
212
213    pub fn on_open_auto_focus(mut self, hook: Option<OnOpenAutoFocus>) -> Self {
214        self.on_open_auto_focus = hook;
215        self
216    }
217
218    pub fn on_close_auto_focus(mut self, hook: Option<OnCloseAutoFocus>) -> Self {
219        self.on_close_auto_focus = hook;
220        self
221    }
222}
223
224/// Converts alert-dialog options into dialog options (modal, non-dismissable by outside press).
225pub fn dialog_options_for_alert_dialog<H: UiHost>(
226    cx: &mut ElementContext<'_, H>,
227    open_id: ModelId,
228    options: AlertDialogOptions,
229) -> DialogOptions {
230    let initial_focus = match options.initial_focus {
231        AlertDialogInitialFocus::None => None,
232        AlertDialogInitialFocus::Element(id) => Some(id),
233        AlertDialogInitialFocus::PreferCancel => cancel_element_for_open_model(cx, open_id),
234    };
235
236    DialogOptions::default()
237        .dismiss_on_overlay_press(false)
238        .initial_focus(initial_focus)
239        .on_open_auto_focus(options.on_open_auto_focus.clone())
240        .on_close_auto_focus(options.on_close_auto_focus.clone())
241}
242
243/// Layout used for a Radix-like alert dialog modal barrier element.
244///
245/// This is a re-export of the shared modal barrier layout from `primitives::dialog`.
246pub fn alert_dialog_modal_barrier_layout() -> LayoutStyle {
247    dialog_prim::modal_barrier_layout()
248}
249
250/// Builds a Radix-style alert-dialog modal barrier (non-dismissable by outside press).
251pub fn alert_dialog_modal_barrier<H: UiHost, I, T>(
252    cx: &mut ElementContext<'_, H>,
253    open: Model<bool>,
254    children: I,
255) -> AnyElement
256where
257    I: IntoIterator<Item = T>,
258    T: IntoUiElement<H>,
259{
260    dialog_prim::modal_barrier(cx, open, false, children)
261}
262
263/// Convenience helper to assemble alert dialog overlay children in a Radix-like order: barrier then
264/// content.
265pub fn alert_dialog_modal_layer_elements<H: UiHost, I, T>(
266    cx: &mut ElementContext<'_, H>,
267    open: Model<bool>,
268    barrier_children: I,
269    content: AnyElement,
270) -> Elements
271where
272    I: IntoIterator<Item = T>,
273    T: IntoUiElement<H>,
274{
275    Elements::from([
276        alert_dialog_modal_barrier(cx, open, barrier_children),
277        content,
278    ])
279}
280
281/// Stamps Radix-like trigger relationships:
282/// - `expanded` mirrors `aria-expanded`
283/// - `controls_element` mirrors `aria-controls` (by element id).
284pub fn apply_alert_dialog_trigger_a11y(
285    trigger: AnyElement,
286    expanded: bool,
287    content_element: Option<GlobalElementId>,
288) -> AnyElement {
289    trigger_a11y::apply_trigger_controls_expanded(trigger, Some(expanded), content_element)
290}
291
292/// Builds an overlay request for a Radix-style modal alert dialog.
293pub fn alert_dialog_modal_request_with_options(
294    id: GlobalElementId,
295    trigger: GlobalElementId,
296    open: Model<bool>,
297    presence: OverlayPresence,
298    options: DialogOptions,
299    children: impl IntoIterator<Item = AnyElement>,
300) -> OverlayRequest {
301    dialog_prim::modal_dialog_request_with_options(id, trigger, open, presence, options, children)
302}
303
304/// Requests a Radix-style modal alert dialog overlay for the current window.
305pub fn request_alert_dialog<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
306    dialog_prim::request_modal_dialog(cx, request);
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    use fret_app::App;
314    use fret_core::{AppWindowId, Point, Px, Rect, Size};
315
316    fn bounds() -> Rect {
317        Rect::new(
318            Point::new(Px(0.0), Px(0.0)),
319            Size::new(Px(200.0), Px(120.0)),
320        )
321    }
322
323    #[test]
324    fn alert_dialog_root_open_model_uses_controlled_model() {
325        let window = AppWindowId::default();
326        let mut app = App::new();
327        let b = bounds();
328
329        let controlled = app.models_mut().insert(true);
330
331        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
332            let root = AlertDialogRoot::new()
333                .open(Some(controlled.clone()))
334                .default_open(false);
335            assert_eq!(root.open_model(cx), controlled);
336        });
337    }
338
339    #[test]
340    fn alert_dialog_root_options_builder_updates_options() {
341        let root = AlertDialogRoot::new().initial_focus(AlertDialogInitialFocus::None);
342        let options = root.options();
343        assert!(matches!(
344            options.initial_focus,
345            AlertDialogInitialFocus::None
346        ));
347    }
348
349    #[test]
350    fn registry_prefers_first_cancel_and_can_be_cleared() {
351        let window = AppWindowId::default();
352        let mut app = App::new();
353        let b = bounds();
354
355        let open = app.models_mut().insert(false);
356        let open_id = open.id();
357        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
358            register_cancel_for_open_model(cx, open_id, GlobalElementId(0xaaa));
359            register_cancel_for_open_model(cx, open_id, GlobalElementId(0xbbb));
360            assert_eq!(
361                cancel_element_for_open_model(cx, open_id),
362                Some(GlobalElementId(0xaaa))
363            );
364            clear_cancel_for_open_model(cx, open_id);
365            assert_eq!(cancel_element_for_open_model(cx, open_id), None);
366        });
367    }
368
369    #[test]
370    fn dialog_options_for_alert_dialog_prefers_cancel() {
371        let window = AppWindowId::default();
372        let mut app = App::new();
373        let b = bounds();
374
375        let open = app.models_mut().insert(false);
376        let open_id = open.id();
377
378        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
379            register_cancel_for_open_model(cx, open_id, GlobalElementId(0xaaa));
380
381            let opts = dialog_options_for_alert_dialog(cx, open_id, AlertDialogOptions::default());
382            assert!(!opts.dismiss_on_overlay_press);
383            assert_eq!(opts.initial_focus, Some(GlobalElementId(0xaaa)));
384        });
385    }
386}