Skip to main content

teksilo_widgets/
dialog.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Modal dialogs — a trigger button that presents a centered modal panel.
5//!
6//! Three cooperating types cover the common dialog use-case. [`Dialog`] is the
7//! high-level entry point: a `Button` (or custom trigger) that, on activation,
8//! presents a `ModalContainer` above a full-viewport dimming [`ModalScrim`].
9//! [`DialogContent`] is the convenience body layout — a `VStack` with an
10//! optional title, supporting text, scrollable body slot, and a footer slot
11//! separated by a `Divider`.
12//!
13//! ## When to use
14//!
15//! - `Dialog::new(label).content(|| …)` for the common "button opens dialog" pattern.
16//! - `Dialog::new(label).trigger(my_icon_button).content(|| …)` to use a custom widget
17//!   as the trigger instead of the default `Button`.
18//! - `ModalContainer::new(content)` directly when you need to present a modal from
19//!   handler code via `ctx.present_modal(ModalRequest::…)` rather than a persistent
20//!   trigger.
21//!
22//! ## Accessibility
23//!
24//! `ModalContainer` is a `Role::Dialog` node and announces `set_modal()`.
25//! Its accessible name defaults to the `DialogContent` title (via
26//! `Widget::accessible_title_hint`) or falls back to the localized
27//! `a11y_dialog_name` message; pass `.title(tr!(…))` to the container for an
28//! explicit override. The trigger button advertises `HasPopup::Dialog` and
29//! `set_expanded` tracks whether the modal is currently open.
30//!
31//! ```ignore
32//! use teksilo_widgets::dialog::{Dialog, DialogContent};
33//! use teksilo_i18n::lit;
34//!
35//! let _d = Dialog::new(lit!("Open settings"))
36//!     .content(|| {
37//!         DialogContent::new()
38//!             .title(lit!("Settings"))
39//!             .supporting_text(lit!("Adjust your preferences below."))
40//!     });
41//! ```
42//!
43//! ## Touch and pen
44//!
45//! The trigger is a `Button` (or, with `.trigger(..)`, the caller's widget wrapped
46//! in the same activation handlers), and both actuate on the release. The footer's
47//! buttons are buttons.
48//!
49//! The scrim is the one full-viewport node that has to receive exactly the presses
50//! that land on it, so it says `no_hit_slop` outright rather than relying on the
51//! slop pass's size formula to exclude it by arithmetic — see
52//! `scrim_hit_targeting_tests` below. Its dismissal is a tap, so it too waits for
53//! the release.
54
55use std::cell::Cell;
56use std::rc::Rc;
57
58use teksilo_canvas::{Rect, Size, SizeProposal};
59use teksilo_core::accessibility::AccessNodeBuilder;
60use teksilo_core::build_context::BuildContext;
61use teksilo_core::event::{EventResponse, Key, WidgetEvent};
62use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
63use teksilo_core::overlay::{OverlayDismissCallback, OverlayId};
64use teksilo_core::signal::{Prop, Signal};
65use teksilo_core::styles::{DialogStyleConfig, SharedDialogStyle};
66use teksilo_core::widget::{EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement};
67use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
68use teksilo_core::widget_id::WidgetId;
69use teksilo_tokens::{TextRole, TextStyleRole};
70
71use crate::button::{Button, ButtonVariant};
72use crate::overlay_trigger::OverlayTrigger;
73use crate::primitives::{Divider, TextWidget, VStack};
74use teksilo_i18n::LocalizedString;
75
76type DialogFactory = std::rc::Rc<dyn Fn() -> Box<dyn Widget>>;
77
78/// Rounded panel chrome that wraps a modal dialog's content widget.
79///
80/// All visual dimensions (padding, corner radius, min-width, shadow) are owned
81/// by the active [`DialogStyle`](teksilo_core::styles::DialogStyle); per-instance
82/// overrides are available via [`Self::padding`] and [`Self::min_width`].
83pub struct ModalContainer {
84    content_id: Option<WidgetId>,
85    pending_content: Option<Box<dyn Widget>>,
86    padding_override: Option<f32>,
87    min_width_override: Option<f32>,
88    /// Explicit accessible title for the dialog. Set via `.title(...)`
89    /// — typically the same string the inner `DialogContent` uses as
90    /// its visual title. When `None`, `accessibility()` falls back to
91    /// the generic i18n `a11y_dialog_name` string so there's always
92    /// a non-empty name for screen readers.
93    /// AT name for the `Role::Dialog` node. Kept as a `LocalizedString`
94    /// (not eagerly resolved) so an explicit `.title(tr!(...))` follows a
95    /// live locale switch — `accessibility()` re-resolves on the AT
96    /// re-walk. The content-derived hint path is wrapped as a literal
97    /// (the core `accessible_title_hint` trait returns a plain `String`,
98    /// since core can't name `LocalizedString`); dialogs rebuild on show
99    /// so the hint is still current-locale at present time.
100    title: Option<LocalizedString>,
101    /// Per-call override for the modal panel chrome. Replaces the
102    /// theme-wide `style_slots.dialog` and the IntUI default
103    /// `RecipeDialogStyle` for just this container.
104    style_override: Option<SharedDialogStyle>,
105    /// Build state — the `DialogStyle::make_panel` root.
106    root_child_id: Option<WidgetId>,
107    /// True once the container has been wired to the title its content
108    /// paints. It must then set no name of its own: the consumer prefers a
109    /// node's own label over its `labelled_by` targets, so doing both would
110    /// silently drop the relation.
111    named_by_content: bool,
112}
113
114impl ModalContainer {
115    /// Wrap `content` inside a modal panel with default chrome.
116    pub fn new(content: impl Widget + 'static) -> Self {
117        Self::boxed(Box::new(content))
118    }
119
120    pub(crate) fn boxed(content: Box<dyn Widget>) -> Self {
121        Self {
122            content_id: None,
123            pending_content: Some(content),
124            padding_override: None,
125            min_width_override: None,
126            title: None,
127            style_override: None,
128            root_child_id: None,
129            named_by_content: false,
130        }
131    }
132
133    /// Override the content padding (logical pixels) from the theme default.
134    pub fn padding(mut self, padding: f32) -> Self {
135        self.padding_override = Some(padding.max(0.0));
136        self
137    }
138
139    /// Override the minimum panel width (logical pixels) from the theme default.
140    pub fn min_width(mut self, min_width: f32) -> Self {
141        self.min_width_override = Some(min_width.max(0.0));
142        self
143    }
144
145    /// Per-call style override for the modal panel chrome. Replaces the
146    /// theme-wide default `DialogStyle` for just this container.
147    pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
148        self.style_override = Some(Rc::new(style));
149        self
150    }
151
152    /// Accessible title for the dialog, announced as the dialog's name
153    /// when the content paints no title of its own. Content that does —
154    /// e.g. `DialogContent::title` — names the dialog by pointing at that
155    /// label and wins over this string, so the two should match.
156    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
157        let ls: LocalizedString = title.into();
158        self.title = Some(ls);
159        self
160    }
161}
162
163impl std::fmt::Debug for ModalContainer {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct("ModalContainer")
166            .field("padding_override", &self.padding_override)
167            .field("min_width_override", &self.min_width_override)
168            .finish()
169    }
170}
171
172impl Widget for ModalContainer {
173    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
174        if let Some(content) = self.pending_content.take() {
175            // If the caller didn't set an explicit `.title(...)`,
176            // ask the content widget for a suggested title — e.g.
177            // `DialogContent::accessible_title_hint` returns its
178            // own visible title. This lets dialogs announce their
179            // real name without forcing callers to duplicate the
180            // string at both the content and the container level.
181            if self.title.is_none()
182                && let Some(hint) = content.accessible_title_hint()
183            {
184                // The core `accessible_title_hint` trait can only return a
185                // plain `String`, so wrap it as a literal. Resolved fresh
186                // at present time (dialogs rebuild on show).
187                self.title = Some(LocalizedString::literal(hint));
188            }
189            let content_id = ctx.add_boxed(content);
190            self.content_id = Some(content_id);
191            // Name the dialog by pointing at the title it already paints,
192            // rather than by a second copy of the string. `build()` runs
193            // eagerly on insertion, so the content's title node exists by
194            // now; an explicit `.title(..)` on the container yields to it,
195            // because the visible title is what a reader will find when
196            // they go looking for the name they heard.
197            if let Some(title_id) = ctx.accessible_title_node(content_id) {
198                let self_id = ctx.self_id();
199                ctx.access_labelled_by(self_id, title_id);
200                self.named_by_content = true;
201            }
202        }
203
204        // The panel chrome (rounded surface + border + content
205        // padding) is owned by the active `DialogStyle`; the modal
206        // mounting / dismissal pipeline stays on this widget.
207        let content_id = self
208            .content_id
209            .expect("ModalContainer requires content — none was set");
210        let style: SharedDialogStyle = self
211            .style_override
212            .clone()
213            .or_else(|| ctx.theme().style_slots.dialog.clone())
214            .unwrap_or_else(|| {
215                Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
216                    &ctx.theme().input,
217                ))
218            });
219        let cfg = DialogStyleConfig {
220            content: content_id,
221            has_scrim: true,
222            padding_override: self.padding_override,
223            min_width_override: self.min_width_override,
224        };
225        let root_id = style.make_panel(&cfg, ctx);
226        self.root_child_id = Some(root_id);
227        vec![root_id]
228    }
229
230    fn layout_response(
231        &self,
232        proposal: SizeProposal,
233        ctx: &LayoutContext,
234    ) -> teksilo_core::widget::LayoutResponse {
235        self.root_child_id
236            .and_then(|id| ctx.child_size(id, proposal))
237            .unwrap_or_else(|| proposal.resolve(240.0, 120.0))
238            .into()
239    }
240
241    fn place_children(
242        &self,
243        bounds: Rect,
244        _proposal: SizeProposal,
245        children: &mut [WidgetPlacement],
246        _ctx: &LayoutContext,
247    ) {
248        for child in children.iter_mut() {
249            child.origin = bounds.origin();
250            child.size = bounds.size();
251        }
252    }
253
254    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
255        builder.set_role(teksilo_core::accesskit::Role::Dialog);
256        // A container named through a relation must not also set a name:
257        // the consumer prefers the node's own label, so setting both would
258        // silently drop the relation and announce a stale copy.
259        if !self.named_by_content {
260            let name = self
261                .title
262                .as_ref()
263                .map(|t| t.resolve_now())
264                .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_dialog_name()).resolve_now());
265            builder.set_name(name);
266        }
267        // ModalContainer is always modal — it's the one path that goes
268        // through `ModalRequest` / `ModalPresentation`. A dialog that
269        // doesn't block outside interaction would use `Popover` instead.
270        builder.set_modal();
271    }
272
273    fn children(&self) -> Vec<WidgetId> {
274        self.root_child_id.into_iter().collect()
275    }
276}
277
278/// Full-viewport dimming scrim painted behind a [`ModalContainer`].
279///
280/// Mounted by the modal-presentation pipeline (teksilo-app) as a separate
281/// `OverlayPlacement::FullViewport` overlay pushed BEFORE the centered
282/// modal overlay so it z-orders below the panel. The chrome itself is
283/// delegated to the active `DialogStyle::make_scrim`; clicking the
284/// scrim dismisses the linked modal when the modal's
285/// [`ModalCloseBehavior`] permits click-outside dismissal.
286///
287/// The dismissal cascade is wired via
288/// `OverlayManager::set_parent_overlay` AFTER both overlays are
289/// pushed — the scrim's `parent_overlay` is set to the modal's id, so
290/// any dismiss of the modal cascades through `dismiss_immediate` and
291/// also dismisses the scrim. The scrim's own `dismiss` behavior is
292/// `Manual` — it never dismisses itself directly.
293pub struct ModalScrim {
294    style_override: Option<SharedDialogStyle>,
295    /// Filled in by the framework AFTER the modal overlay is pushed
296    /// — the scrim is mounted FIRST (so it z-orders below the modal),
297    /// so the modal's `OverlayId` isn't yet known at build time. The
298    /// scrim's on-tap closure reads through this `Cell` at click time
299    /// rather than capturing a value that doesn't exist yet.
300    dismiss_target: Rc<Cell<Option<OverlayId>>>,
301    /// Whether clicking the scrim should dismiss `dismiss_target`.
302    /// Reflects the modal's [`ModalCloseBehavior`]: `true` for
303    /// `ClickOutside` and `EscapeOrClickOutside`; `false` for
304    /// `EscapeKey` and `Manual` (clicks on the dim are absorbed but
305    /// do not dismiss).
306    click_to_dismiss: bool,
307    root_child_id: Option<WidgetId>,
308}
309
310impl ModalScrim {
311    /// Build a new scrim; wire it with [`Self::dismiss_target`] and
312    /// [`Self::click_to_dismiss`] after construction.
313    pub fn new() -> Self {
314        Self {
315            style_override: None,
316            dismiss_target: Rc::new(Cell::new(None)),
317            click_to_dismiss: false,
318            root_child_id: None,
319        }
320    }
321
322    /// Per-call style override for the scrim chrome. Replaces the
323    /// theme-wide default `DialogStyle` for just this scrim.
324    pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
325        self.style_override = Some(Rc::new(style));
326        self
327    }
328
329    /// Handle to the modal-overlay id the scrim dismisses on click.
330    /// The framework fills this AFTER the modal is pushed (see the
331    /// in-tree modal pipeline in `teksilo-app`).
332    pub fn dismiss_target(mut self, target: Rc<Cell<Option<OverlayId>>>) -> Self {
333        self.dismiss_target = target;
334        self
335    }
336
337    /// Enable click-to-dismiss on the scrim. Should mirror whether the
338    /// modal's [`ModalCloseBehavior`] permits click-outside dismissal.
339    pub fn click_to_dismiss(mut self, enabled: bool) -> Self {
340        self.click_to_dismiss = enabled;
341        self
342    }
343}
344
345impl Default for ModalScrim {
346    fn default() -> Self {
347        Self::new()
348    }
349}
350
351impl std::fmt::Debug for ModalScrim {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        f.debug_struct("ModalScrim")
354            .field("click_to_dismiss", &self.click_to_dismiss)
355            .finish()
356    }
357}
358
359impl Widget for ModalScrim {
360    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
361        let style: SharedDialogStyle = self
362            .style_override
363            .clone()
364            .or_else(|| ctx.theme().style_slots.dialog.clone())
365            .unwrap_or_else(|| {
366                Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
367                    &ctx.theme().input,
368                ))
369            });
370        let chrome_id = style.make_scrim(ctx);
371
372        // A scrim is never re-attributed to and never widens. The size formula
373        // already excludes a full-viewport node from the slop pass by
374        // arithmetic, but a scrim's whole contract is that it receives exactly
375        // the presses that land on it — saying so outright means the guarantee
376        // does not quietly depend on how large the scrim happens to be.
377        let mut handlers = HandlerSet::new().no_hit_slop();
378        if self.click_to_dismiss {
379            let target = self.dismiss_target.clone();
380            handlers = handlers.on_tap(move |_event, ctx| {
381                if let Some(modal_id) = target.get() {
382                    ctx.dismiss_overlay(modal_id);
383                }
384            });
385        }
386        ctx.apply_self_handlers(handlers);
387
388        self.root_child_id = Some(chrome_id);
389        vec![chrome_id]
390    }
391
392    fn layout_response(
393        &self,
394        proposal: SizeProposal,
395        ctx: &LayoutContext,
396    ) -> teksilo_core::widget::LayoutResponse {
397        // The scrim's actual size is determined by
398        // `OverlayPlacement::FullViewport` in `position_overlays`,
399        // which overrides the intrinsic size to the full viewport. We
400        // still report the child's wanted size so the proposal flows
401        // correctly when the framework probes the intrinsic size.
402        self.root_child_id
403            .and_then(|id| ctx.child_size(id, proposal))
404            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
405            .into()
406    }
407
408    fn place_children(
409        &self,
410        bounds: Rect,
411        _proposal: SizeProposal,
412        children: &mut [WidgetPlacement],
413        _ctx: &LayoutContext,
414    ) {
415        for child in children.iter_mut() {
416            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
417            child.size = Size::new(bounds.width, bounds.height);
418        }
419    }
420
421    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
422        // Hidden from the AT: the modal panel above carries the
423        // `Role::Dialog` node with the accessible name.
424        builder.set_hidden();
425    }
426
427    fn children(&self) -> Vec<WidgetId> {
428        self.root_child_id.into_iter().collect()
429    }
430}
431
432fn queue_dialog_request(
433    ctx: &mut EventContext,
434    factory: &DialogFactory,
435    presentation: ModalPresentation,
436    close_behavior: ModalCloseBehavior,
437    title: &str,
438    on_dismiss: Option<OverlayDismissCallback>,
439) {
440    let factory = factory.clone();
441    let mut request = ModalRequest::deferred(move |tree| {
442        let content = (factory.as_ref())();
443        tree.add(ModalContainer::boxed(content))
444    })
445    .presentation(presentation)
446    .close_behavior(close_behavior)
447    .title(title)
448    .size(460, 260);
449    if let Some(cb) = on_dismiss {
450        request = request.on_dismiss(cb);
451    }
452    ctx.present_modal(request);
453}
454
455/// Convenience body layout for a modal dialog: optional title, supporting text,
456/// scrollable body slot, and a `Divider`-separated footer row.
457pub struct DialogContent {
458    title: Option<LocalizedString>,
459    supporting_text: Option<LocalizedString>,
460    pending_body: Option<PendingChild>,
461    pending_footer: Option<PendingChild>,
462    root_child_id: Option<WidgetId>,
463    /// The label that paints the title, handed to the enclosing container
464    /// so it can name itself by pointing at it.
465    title_node: Option<WidgetId>,
466}
467
468impl DialogContent {
469    /// Create an empty dialog body with no sections set.
470    pub fn new() -> Self {
471        Self {
472            title: None,
473            supporting_text: None,
474            pending_body: None,
475            pending_footer: None,
476            root_child_id: None,
477            title_node: None,
478        }
479    }
480
481    /// Bold title shown at the top of the content area. Also propagated to
482    /// the enclosing `ModalContainer` via `accessible_title_hint`.
483    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
484        self.title = Some(title.into());
485        self
486    }
487
488    /// Secondary description text shown below the title.
489    pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self {
490        self.supporting_text = Some(text.into());
491        self
492    }
493
494    /// Main scrollable content slot (any widget).
495    pub fn body(mut self, body: impl teksilo_core::IntoTeksiChild) -> Self {
496        self.pending_body = Some(teksilo_core::IntoTeksiChild::into_pending(body));
497        self
498    }
499
500    /// Footer slot separated from the body by a `Divider` (typically action
501    /// buttons like "OK" / "Cancel").
502    pub fn footer(mut self, footer: impl teksilo_core::IntoTeksiChild) -> Self {
503        self.pending_footer = Some(teksilo_core::IntoTeksiChild::into_pending(footer));
504        self
505    }
506}
507
508impl Default for DialogContent {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514impl std::fmt::Debug for DialogContent {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        f.debug_struct("DialogContent")
517            .field("title", &self.title)
518            .field("supporting_text", &self.supporting_text)
519            .finish()
520    }
521}
522
523impl Widget for DialogContent {
524    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
525        let mut stack = VStack::new().spacing(16.0);
526
527        if self.title.is_some() || self.supporting_text.is_some() {
528            let mut header = VStack::new().spacing(8.0);
529            if let Some(title) = self.title.clone() {
530                // Kept by id, not by value: the enclosing container names
531                // itself by pointing at this node rather than copying its
532                // string, so the title stays a label a reader can review by
533                // character in its own right.
534                let title_id = ctx.add(
535                    TextWidget::new(title)
536                        .style(TextStyleRole::BodyBold)
537                        .color(TextRole::Primary)
538                        .single_line(),
539                );
540                self.title_node = Some(title_id);
541                header = header.child(title_id);
542            }
543            if let Some(text) = self.supporting_text.clone() {
544                header = header.child(
545                    TextWidget::new(text)
546                        .style(TextStyleRole::Body)
547                        .color(TextRole::Secondary),
548                );
549            }
550            let header_id = ctx.add(header);
551            stack = stack.child(header_id);
552        }
553
554        if let Some(body) = self.pending_body.take() {
555            let body_id = match body {
556                PendingChild::Id(id) => id,
557                PendingChild::Deferred(w) => ctx.add_boxed(w),
558            };
559            stack = stack.child(body_id);
560        }
561
562        if let Some(footer) = self.pending_footer.take() {
563            let divider_id = ctx.add(Divider::new());
564            let footer_id = match footer {
565                PendingChild::Id(id) => id,
566                PendingChild::Deferred(w) => ctx.add_boxed(w),
567            };
568            stack = stack.child(divider_id).child(footer_id);
569        }
570
571        let root = ctx.add(stack);
572        self.root_child_id = Some(root);
573        vec![root]
574    }
575
576    fn layout_response(
577        &self,
578        proposal: SizeProposal,
579        ctx: &LayoutContext,
580    ) -> teksilo_core::widget::LayoutResponse {
581        self.root_child_id
582            .and_then(|id| ctx.child_size(id, proposal))
583            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
584            .into()
585    }
586
587    fn place_children(
588        &self,
589        bounds: Rect,
590        _proposal: SizeProposal,
591        children: &mut [WidgetPlacement],
592        _ctx: &LayoutContext,
593    ) {
594        for child in children.iter_mut() {
595            child.origin = bounds.origin();
596            child.size = bounds.size();
597        }
598    }
599
600    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
601        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
602    }
603
604    /// Expose the visible title to an enclosing `ModalContainer`
605    /// (or any other shell) so it can use it as its own accessible
606    /// name without the caller having to thread the same string
607    /// through twice.
608    fn accessible_title_node(&self) -> Option<WidgetId> {
609        self.title_node
610    }
611
612    fn accessible_title_hint(&self) -> Option<String> {
613        self.title.as_ref().map(|t| t.resolve_now())
614    }
615
616    fn children(&self) -> Vec<WidgetId> {
617        self.root_child_id.into_iter().collect()
618    }
619}
620
621/// A trigger button that presents a modal dialog when activated.
622///
623/// Renders as a `Button` by default; call `.trigger(w)` to replace it with any
624/// widget. The content is lazily constructed by a factory closure each time the
625/// dialog opens — no persistent widget subtree is kept while the dialog is closed.
626pub struct Dialog {
627    label: LocalizedString,
628    variant: ButtonVariant,
629    /// Enabled state, static or reactive; forwarded to the trigger at
630    /// build time.
631    enabled: Prop<bool>,
632    presentation: ModalPresentation,
633    close_behavior: ModalCloseBehavior,
634    content_factory: Option<DialogFactory>,
635    pending_trigger: Option<PendingChild>,
636    root_child_id: Option<WidgetId>,
637}
638
639impl Dialog {
640    /// Build a dialog trigger with `label` as the button text and accessible name.
641    pub fn new(label: impl Into<LocalizedString>) -> Self {
642        Self {
643            label: label.into(),
644            variant: ButtonVariant::Filled,
645            enabled: Prop::Static(true),
646            presentation: ModalPresentation::Auto,
647            close_behavior: ModalCloseBehavior::EscapeOrClickOutside,
648            content_factory: None,
649            pending_trigger: None,
650            root_child_id: None,
651        }
652    }
653
654    /// Factory closure that builds the dialog's content each time it opens.
655    /// Required — the dialog panics at build time if no factory is set.
656    pub fn content<W, F>(mut self, factory: F) -> Self
657    where
658        W: Widget + 'static,
659        F: Fn() -> W + 'static,
660    {
661        self.content_factory = Some(std::rc::Rc::new(move || {
662            Box::new(factory()) as Box<dyn Widget>
663        }));
664        self
665    }
666
667    /// Visual style of the default trigger button. Has no effect when
668    /// `.trigger(…)` replaces the button with a custom widget.
669    pub fn variant(mut self, variant: ButtonVariant) -> Self {
670        self.variant = variant;
671        self
672    }
673
674    /// Enable or disable the trigger button, statically or reactively
675    /// (default `true`).
676    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
677        self.enabled = enabled.into();
678        self
679    }
680
681    /// Override the modal presentation mode (default `ModalPresentation::Auto`).
682    pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
683        self.presentation = presentation;
684        self
685    }
686
687    /// Override how the dialog may be closed (default `EscapeOrClickOutside`).
688    pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
689        self.close_behavior = close_behavior;
690        self
691    }
692
693    /// Replace the default `Button` trigger with a custom widget. The widget
694    /// receives the same tap / key / AT-action handlers as the button would.
695    pub fn trigger(mut self, trigger: impl teksilo_core::IntoTeksiChild) -> Self {
696        self.pending_trigger = Some(teksilo_core::IntoTeksiChild::into_pending(trigger));
697        self
698    }
699}
700
701impl std::fmt::Debug for Dialog {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("Dialog")
704            .field("label", &self.label)
705            .field("style", &self.variant)
706            .field("enabled", &self.enabled.get())
707            .finish()
708    }
709}
710
711impl Widget for Dialog {
712    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
713        let label = self.label.clone();
714        // Live signal view of the enabled state — the manual gates below
715        // run inside event closures dispatched later, so a plain `bool`
716        // snapshot captured here would go stale for a `Prop::Bound`
717        // value. `.as_signal()` returns the underlying signal when bound,
718        // or wraps a static value in a fresh `Signal::new(v)`.
719        let enabled = self.enabled.as_signal();
720        let close_behavior = self.close_behavior;
721        let presentation = self.presentation;
722        let style = self.variant;
723        let content_factory = self
724            .content_factory
725            .clone()
726            .expect("Dialog requires .content(...) — no content factory was set");
727
728        // Track whether the modal is currently open so the trigger can set
729        // aria-expanded correctly. The dismiss callback resets it to false
730        // regardless of which close path fires (Escape, click-outside, explicit
731        // ctx.dismiss_modal()). Only in-tree presentations fire this callback.
732        let is_open: Signal<bool> = ctx.signal(false);
733        let dismiss_callback: OverlayDismissCallback = {
734            let is_open = is_open.clone();
735            std::rc::Rc::new(move |_, _| {
736                is_open.set(false);
737            })
738        };
739
740        let root_id = if let Some(trigger) = self.pending_trigger.take() {
741            let tap_open = is_open.clone();
742            let tap_dismiss = dismiss_callback.clone();
743            let key_open = is_open.clone();
744            let key_dismiss = dismiss_callback.clone();
745            let action_open = is_open.clone();
746            let action_dismiss = dismiss_callback.clone();
747            let handlers = teksilo_core::widget_builder::HandlerSet::new()
748                .focusable(true)
749                .cursor(teksilo_core::widget::CursorIcon::Pointer)
750                .on_tap({
751                    let label = label.clone();
752                    let content_factory = content_factory.clone();
753                    let enabled = enabled.clone();
754                    move |_pos, ctx| {
755                        if !enabled.get() {
756                            return;
757                        }
758                        tap_open.set(true);
759                        queue_dialog_request(
760                            ctx,
761                            &content_factory,
762                            presentation,
763                            close_behavior,
764                            &label.resolve_now(),
765                            Some(tap_dismiss.clone()),
766                        );
767                    }
768                })
769                .on_key({
770                    let label = label.clone();
771                    let content_factory = content_factory.clone();
772                    let enabled = enabled.clone();
773                    move |event, ctx| match event {
774                        WidgetEvent::KeyUp {
775                            key: Key::Enter | Key::Space,
776                            ..
777                        } if enabled.get() => {
778                            key_open.set(true);
779                            queue_dialog_request(
780                                ctx,
781                                &content_factory,
782                                presentation,
783                                close_behavior,
784                                &label.resolve_now(),
785                                Some(key_dismiss.clone()),
786                            );
787                            EventResponse::Handled
788                        }
789                        _ => EventResponse::Ignored,
790                    }
791                });
792            // The AT route goes on the OverlayTrigger's OWN node rather than
793            // into the set above, which is applied to the child. The trigger
794            // node is the one carrying `Role::Button`, and an `AccessAction`
795            // bubbles from the node it was invoked on towards the root — so a
796            // handler on the child sat off that path and this button, named
797            // and correctly roled, could not be activated by a screen reader.
798            let on_access_activate = {
799                let label = label.clone();
800                let content_factory = content_factory.clone();
801                let enabled = enabled.clone();
802                move |ctx: &mut teksilo_core::widget::EventContext| {
803                    if !enabled.get() {
804                        return;
805                    }
806                    action_open.set(true);
807                    queue_dialog_request(
808                        ctx,
809                        &content_factory,
810                        presentation,
811                        close_behavior,
812                        &label.resolve_now(),
813                        Some(action_dismiss.clone()),
814                    );
815                }
816            };
817            let overlay_trigger = match trigger {
818                PendingChild::Id(id) => OverlayTrigger::from_id(id, handlers),
819                PendingChild::Deferred(widget) => OverlayTrigger::new(widget, handlers),
820            }
821            .on_access_activate(on_access_activate)
822            .enabled(self.enabled.clone())
823            .name(label)
824            .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
825            .expanded_when(is_open.clone());
826            ctx.add(overlay_trigger)
827        } else {
828            let tap_open = is_open.clone();
829            let tap_dismiss = dismiss_callback.clone();
830            let key_open = is_open.clone();
831            let key_dismiss = dismiss_callback.clone();
832            let action_open = is_open.clone();
833            let action_dismiss = dismiss_callback.clone();
834            ctx.add(
835                Button::new(label)
836                    .variant(style)
837                    .enabled(enabled.clone())
838                    .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
839                    .expanded_when(is_open.clone())
840                    .on_tap({
841                        let label = self.label.clone();
842                        let content_factory = content_factory.clone();
843                        let enabled = enabled.clone();
844                        move |_pos, ctx| {
845                            if !enabled.get() {
846                                return;
847                            }
848                            tap_open.set(true);
849                            queue_dialog_request(
850                                ctx,
851                                &content_factory,
852                                presentation,
853                                close_behavior,
854                                &label.resolve_now(),
855                                Some(tap_dismiss.clone()),
856                            );
857                        }
858                    })
859                    .on_key({
860                        let label = self.label.clone();
861                        let content_factory = content_factory.clone();
862                        let enabled = enabled.clone();
863                        move |event, ctx| match event {
864                            WidgetEvent::KeyUp {
865                                key: Key::Enter | Key::Space,
866                                ..
867                            } if enabled.get() => {
868                                key_open.set(true);
869                                queue_dialog_request(
870                                    ctx,
871                                    &content_factory,
872                                    presentation,
873                                    close_behavior,
874                                    &label.resolve_now(),
875                                    Some(key_dismiss.clone()),
876                                );
877                                EventResponse::Handled
878                            }
879                            _ => EventResponse::Ignored,
880                        }
881                    })
882                    .on_access_action({
883                        let label = self.label.clone();
884                        let content_factory = content_factory.clone();
885                        let enabled = enabled.clone();
886                        move |action, ctx| {
887                            if action == teksilo_core::accesskit::Action::Click && enabled.get() {
888                                action_open.set(true);
889                                queue_dialog_request(
890                                    ctx,
891                                    &content_factory,
892                                    presentation,
893                                    close_behavior,
894                                    &label.resolve_now(),
895                                    Some(action_dismiss.clone()),
896                                );
897                                EventResponse::Handled
898                            } else {
899                                EventResponse::Ignored
900                            }
901                        }
902                    }),
903            )
904        };
905
906        self.root_child_id = Some(root_id);
907        vec![root_id]
908    }
909
910    fn layout_response(
911        &self,
912        proposal: SizeProposal,
913        ctx: &LayoutContext,
914    ) -> teksilo_core::widget::LayoutResponse {
915        self.root_child_id
916            .and_then(|id| ctx.child_size(id, proposal))
917            .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
918            .into()
919    }
920
921    fn place_children(
922        &self,
923        bounds: Rect,
924        _proposal: SizeProposal,
925        children: &mut [WidgetPlacement],
926        _ctx: &LayoutContext,
927    ) {
928        for child in children.iter_mut() {
929            child.origin = bounds.origin();
930            child.size = bounds.size();
931        }
932    }
933
934    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
935        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
936    }
937
938    fn children(&self) -> Vec<WidgetId> {
939        self.root_child_id.into_iter().collect()
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use teksilo_canvas::Size;
947    use teksilo_core::widget_tree::WidgetTree;
948    use teksilo_core::{ModalContent, ModalPresentation};
949    use teksilo_i18n::lit;
950
951    #[derive(Debug)]
952    struct FixedLeaf(f32, f32);
953
954    impl Widget for FixedLeaf {
955        fn layout_response(
956            &self,
957            _proposal: SizeProposal,
958            _ctx: &LayoutContext,
959        ) -> teksilo_core::widget::LayoutResponse {
960            Size::new(self.0, self.1).into()
961        }
962    }
963
964    #[test]
965    fn access_click_opens_centered_dialog_overlay() {
966        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
967        tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
968        tree.layout(SizeProposal::exact(800.0, 600.0));
969
970        let trigger = tree.find_by_label("Open dialog").unwrap();
971        tree.dispatch_event(WidgetEvent::AccessAction {
972            action: teksilo_core::accesskit::Action::Click,
973            target: Some(trigger),
974            target_node: teksilo_core::accessibility::root_node_id(),
975            data: None,
976        });
977
978        let requests = tree.drain_pending_modal_requests();
979        assert_eq!(requests.len(), 1);
980        assert_eq!(requests[0].request.presentation, ModalPresentation::Auto);
981        assert_eq!(
982            requests[0].request.close_behavior,
983            ModalCloseBehavior::EscapeOrClickOutside,
984        );
985        assert!(matches!(
986            requests[0].request.content,
987            ModalContent::Deferred(_)
988        ));
989    }
990
991    #[test]
992    fn dialog_surface_exposes_dialog_role() {
993        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
994        tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
995        tree.layout(SizeProposal::exact(800.0, 600.0));
996
997        let trigger = tree.find_by_label("Open dialog").unwrap();
998        tree.dispatch_event(WidgetEvent::AccessAction {
999            action: teksilo_core::accesskit::Action::Click,
1000            target: Some(trigger),
1001            target_node: teksilo_core::accessibility::root_node_id(),
1002            data: None,
1003        });
1004
1005        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1006        let content_id = match request.content {
1007            ModalContent::Deferred(builder) => builder(&mut tree),
1008            ModalContent::ExistingWidget(_) => {
1009                unreachable!("dialog now always uses deferred content")
1010            }
1011        };
1012        tree.layout(SizeProposal::exact(800.0, 600.0));
1013
1014        let dialog = tree
1015            .find_by_role(teksilo_core::accesskit::Role::Dialog)
1016            .unwrap();
1017        let info = tree.accessibility_node(dialog);
1018        assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1019        assert!(tree.bounds(content_id).width > 0.0);
1020    }
1021
1022    /// The name an adapter would announce for `id`, resolved the way the
1023    /// consumer resolves it — through `labelled_by` when the node carries
1024    /// no name of its own.
1025    fn announced_name(tree: &mut WidgetTree, id: WidgetId) -> Option<String> {
1026        let update = tree.sync_accessibility();
1027        let target = teksilo_core::accessibility::widget_id_to_node_id(id);
1028        let consumer = accesskit_consumer::Tree::new(update, false);
1029        let state = consumer.state();
1030        let mut stack = vec![state.root()];
1031        while let Some(node) = stack.pop() {
1032            if node.locate().0 == target {
1033                return node.label();
1034            }
1035            for child in node.children() {
1036                stack.push(child);
1037            }
1038        }
1039        None
1040    }
1041
1042    #[test]
1043    fn modal_container_is_named_by_the_title_its_content_paints() {
1044        // The container points at the title label rather than copying its
1045        // string, so the title is announced once and stays a label a reader
1046        // can find and review by character.
1047        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1048        let container = tree.add(ModalContainer::new(
1049            DialogContent::new()
1050                .title(lit!("Delete file?"))
1051                .body(FixedLeaf(100.0, 40.0)),
1052        ));
1053        tree.layout(SizeProposal::exact(600.0, 400.0));
1054        assert_eq!(
1055            tree.accessibility_node(container).role(),
1056            teksilo_core::accesskit::Role::Dialog
1057        );
1058        assert_eq!(
1059            announced_name(&mut tree, container).as_deref(),
1060            Some("Delete file?")
1061        );
1062    }
1063
1064    #[test]
1065    fn an_explicit_title_yields_to_the_one_on_screen() {
1066        // `.title(..)` on the container used to win. It no longer does: a
1067        // name a reader hears but cannot find anywhere on screen is worse
1068        // than one they can, and the two are only ever meant to be the same
1069        // string. The explicit title still stands when the content paints
1070        // no title of its own.
1071        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1072        let container = tree.add(
1073            ModalContainer::new(
1074                DialogContent::new()
1075                    .title(lit!("Inner title"))
1076                    .body(FixedLeaf(100.0, 40.0)),
1077            )
1078            .title(lit!("Outer title")),
1079        );
1080        tree.layout(SizeProposal::exact(600.0, 400.0));
1081        assert_eq!(
1082            announced_name(&mut tree, container).as_deref(),
1083            Some("Inner title")
1084        );
1085
1086        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1087        let untitled = tree.add(
1088            ModalContainer::new(DialogContent::new().body(FixedLeaf(100.0, 40.0)))
1089                .title(lit!("Outer title")),
1090        );
1091        tree.layout(SizeProposal::exact(600.0, 400.0));
1092        assert_eq!(
1093            announced_name(&mut tree, untitled).as_deref(),
1094            Some("Outer title")
1095        );
1096    }
1097
1098    /// A panel that directs initial focus to its *second* child.
1099    ///
1100    /// The shape real dialogs have: the first focusable descendant is the
1101    /// close button in the title strip, and focus must land on the first form
1102    /// field instead — otherwise the dialog opens focused on "dismiss me" and
1103    /// swallows whatever the user types first.
1104    #[derive(Debug)]
1105    struct HintingPanel {
1106        first: Rc<std::cell::Cell<Option<WidgetId>>>,
1107        hinted: Rc<std::cell::Cell<Option<WidgetId>>>,
1108    }
1109
1110    impl Widget for HintingPanel {
1111        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1112            let first = ctx.add(FixedLeaf(40.0, 20.0));
1113            let hinted = ctx.add(FixedLeaf(40.0, 20.0));
1114            self.first.set(Some(first));
1115            self.hinted.set(Some(hinted));
1116            vec![first, hinted]
1117        }
1118
1119        fn initial_focus_hint(&self) -> Option<WidgetId> {
1120            self.hinted.get()
1121        }
1122
1123        fn layout_response(
1124            &self,
1125            _proposal: SizeProposal,
1126            _ctx: &LayoutContext,
1127        ) -> teksilo_core::widget::LayoutResponse {
1128            Size::new(220.0, 120.0).into()
1129        }
1130    }
1131
1132    /// Wrapping content in a `ModalContainer` must not cost it the ability to
1133    /// direct initial focus.
1134    ///
1135    /// `ModalContainer` does **not** override `initial_focus_hint`, and does not
1136    /// need to: `WidgetTree::widget_initial_focus_hint` walks the subtree and
1137    /// finds the content's own hint through the container and its chrome panel.
1138    /// Nothing pinned that before, which made it look like a missing feature
1139    /// rather than a load-bearing one — and an app about to move a dozen
1140    /// hand-chromed panels onto `ModalContainer` is betting on it.
1141    ///
1142    /// If that walk is ever flattened to "ask the content root, then give up",
1143    /// every wrapped dialog silently reopens focused on its close button.
1144    #[test]
1145    fn modal_container_lets_its_content_direct_initial_focus() {
1146        let first = Rc::new(std::cell::Cell::new(None));
1147        let hinted = Rc::new(std::cell::Cell::new(None));
1148
1149        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1150        let container = tree.add(ModalContainer::new(HintingPanel {
1151            first: first.clone(),
1152            hinted: hinted.clone(),
1153        }));
1154        tree.layout(SizeProposal::exact(600.0, 400.0));
1155
1156        let target = tree.widget_initial_focus_hint(container);
1157        assert_eq!(
1158            target,
1159            hinted.get(),
1160            "the content's hint must survive being wrapped in a ModalContainer"
1161        );
1162        assert_ne!(
1163            target,
1164            first.get(),
1165            "…and must not fall back to the first descendant, which is the \
1166             close button in a real dialog"
1167        );
1168    }
1169
1170    /// The other half: the walk reports a hint, it does not invent one. Content
1171    /// with nothing to say leaves the pipeline free to fall through to
1172    /// `first_focusable_descendant`.
1173    #[test]
1174    fn modal_container_without_a_hint_reports_none() {
1175        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1176        let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1177        tree.layout(SizeProposal::exact(600.0, 400.0));
1178
1179        assert_eq!(tree.widget_initial_focus_hint(container), None);
1180    }
1181
1182    #[test]
1183    fn modal_container_preserves_shell_sizing_defaults() {
1184        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1185        let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1186        tree.layout(SizeProposal {
1187            width: None,
1188            height: None,
1189        });
1190
1191        // DialogStyle defaults: 24 dp content_padding, 280 dp min_width.
1192        // Content 220×120 + 48 padding = 268×168, clamped to 280×168.
1193        let bounds = tree.bounds(container);
1194        assert!((bounds.width - 280.0).abs() < 0.01);
1195        assert!((bounds.height - 168.0).abs() < 0.01);
1196    }
1197
1198    #[test]
1199    fn modal_container_custom_padding_changes_layout() {
1200        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1201        let container = tree.add(
1202            ModalContainer::new(FixedLeaf(220.0, 120.0))
1203                .padding(12.0)
1204                .min_width(200.0),
1205        );
1206        tree.layout(SizeProposal {
1207            width: None,
1208            height: None,
1209        });
1210
1211        let bounds = tree.bounds(container);
1212        assert!((bounds.width - 244.0).abs() < 0.01);
1213        assert!((bounds.height - 144.0).abs() < 0.01);
1214    }
1215
1216    #[test]
1217    fn custom_trigger_opens_dialog_overlay() {
1218        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1219        tree.add(
1220            Dialog::new(lit!("Open dialog"))
1221                .content(|| FixedLeaf(220.0, 120.0))
1222                .trigger(FixedLeaf(140.0, 40.0)),
1223        );
1224        tree.layout(SizeProposal::exact(800.0, 600.0));
1225
1226        // The OverlayTrigger now routes its handlers onto the trigger
1227        // child (so real `Button` triggers, which install their own
1228        // gesture arena, can't consume the tap before the opener
1229        // fires). Clicking the wrapper hit-tests into the child, which
1230        // is where the handler lives.
1231        let trigger = tree.find_by_label("Open dialog").unwrap();
1232        tree.click(trigger);
1233
1234        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1235    }
1236
1237    #[test]
1238    fn a_custom_trigger_advertises_and_answers_the_at_click() {
1239        // Finding 2. The handler existed and worked when hand-invoked, but it
1240        // sat on the child while `Role::Button` sat on the wrapper, and the
1241        // wrapper advertised no actions at all: a node that reads as a
1242        // well-formed, named button and that no screen reader can press.
1243        // Both halves are asserted, because either alone still leaves it
1244        // unusable.
1245        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1246        tree.add(
1247            Dialog::new(lit!("Open dialog"))
1248                .content(|| FixedLeaf(220.0, 120.0))
1249                .trigger(FixedLeaf(140.0, 40.0)),
1250        );
1251        tree.layout(SizeProposal::exact(800.0, 600.0));
1252
1253        let trigger = tree.find_by_label("Open dialog").unwrap();
1254        assert_eq!(
1255            tree.accessibility_node(trigger).role(),
1256            teksilo_core::accesskit::Role::Button
1257        );
1258        assert!(
1259            tree.accessibility_node(trigger)
1260                .actions()
1261                .contains(&teksilo_core::accesskit::Action::Click),
1262            "the node carrying Role::Button must advertise Click"
1263        );
1264
1265        // …and invoking it on THAT node — the one an adapter would target —
1266        // must actually open the dialog. Dispatched through the same entry
1267        // point the platform adapters use.
1268        let handled = tree.dispatch_access_action(
1269            teksilo_core::accessibility::widget_id_to_node_id(trigger),
1270            teksilo_core::accesskit::Action::Click,
1271            None,
1272            &mut teksilo_core::NoopWindowOps,
1273        );
1274        assert!(handled, "the AT Click must be reported as handled");
1275        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1276    }
1277
1278    #[test]
1279    fn rebuilding_a_custom_trigger_does_not_stack_its_at_handler() {
1280        // `OverlayTrigger` installs the AT route on itself on every build,
1281        // and `EventHandlers` MERGES access handlers rather than replacing
1282        // them — so a trigger rebuilt three times could plausibly open three
1283        // dialogs from one AT click. It does not; this is what says so.
1284        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1285        let d = tree.add(
1286            Dialog::new(lit!("Open dialog"))
1287                .content(|| FixedLeaf(220.0, 120.0))
1288                .trigger(FixedLeaf(140.0, 40.0)),
1289        );
1290        tree.layout(SizeProposal::exact(800.0, 600.0));
1291        for _ in 0..3 {
1292            tree.arena_mark_needs_rebuild_for_testing(d);
1293            tree.layout(SizeProposal::exact(800.0, 600.0));
1294        }
1295        let trigger = tree.find_by_label("Open dialog").unwrap();
1296        tree.dispatch_access_action(
1297            teksilo_core::accessibility::widget_id_to_node_id(trigger),
1298            teksilo_core::accesskit::Action::Click,
1299            None,
1300            &mut teksilo_core::NoopWindowOps,
1301        );
1302        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1303    }
1304
1305    #[test]
1306    fn dialog_content_helper_builds_dialog_sections() {
1307        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1308        tree.add(Dialog::new(lit!("Open dialog")).content(|| {
1309            DialogContent::new()
1310                .title(lit!("Review Changes"))
1311                .supporting_text(lit!("Confirm the staged updates before continuing."))
1312                .body(FixedLeaf(220.0, 120.0))
1313                .footer(Button::new(lit!("Close")))
1314        }));
1315        tree.layout(SizeProposal::exact(800.0, 600.0));
1316
1317        let trigger = tree.find_by_label("Open dialog").unwrap();
1318        tree.dispatch_event(WidgetEvent::AccessAction {
1319            action: teksilo_core::accesskit::Action::Click,
1320            target: Some(trigger),
1321            target_node: teksilo_core::accessibility::root_node_id(),
1322            data: None,
1323        });
1324
1325        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1326        match request.content {
1327            ModalContent::Deferred(builder) => {
1328                builder(&mut tree);
1329            }
1330            ModalContent::ExistingWidget(_) => {
1331                unreachable!("dialog now always uses deferred content")
1332            }
1333        }
1334        tree.layout(SizeProposal::exact(800.0, 600.0));
1335
1336        assert!(tree.find_by_label("Review Changes").is_some());
1337        assert!(tree.find_by_label("Close").is_some());
1338    }
1339
1340    #[test]
1341    fn dialog_presentation_can_be_overridden() {
1342        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1343        tree.add(
1344            Dialog::new(lit!("Open dialog"))
1345                .content(|| FixedLeaf(220.0, 120.0))
1346                .presentation(ModalPresentation::InTree),
1347        );
1348        tree.layout(SizeProposal::exact(800.0, 600.0));
1349
1350        let trigger = tree.find_by_label("Open dialog").unwrap();
1351        tree.dispatch_event(WidgetEvent::AccessAction {
1352            action: teksilo_core::accesskit::Action::Click,
1353            target: Some(trigger),
1354            target_node: teksilo_core::accessibility::root_node_id(),
1355            data: None,
1356        });
1357
1358        let requests = tree.drain_pending_modal_requests();
1359        assert_eq!(requests.len(), 1);
1360        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
1361    }
1362
1363    #[test]
1364    fn dialog_close_behavior_can_be_overridden() {
1365        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1366        tree.add(
1367            Dialog::new(lit!("Open dialog"))
1368                .content(|| FixedLeaf(220.0, 120.0))
1369                .close_behavior(ModalCloseBehavior::Manual),
1370        );
1371        tree.layout(SizeProposal::exact(800.0, 600.0));
1372
1373        let trigger = tree.find_by_label("Open dialog").unwrap();
1374        tree.dispatch_event(WidgetEvent::AccessAction {
1375            action: teksilo_core::accesskit::Action::Click,
1376            target: Some(trigger),
1377            target_node: teksilo_core::accessibility::root_node_id(),
1378            data: None,
1379        });
1380
1381        let requests = tree.drain_pending_modal_requests();
1382        assert_eq!(requests.len(), 1);
1383        assert_eq!(
1384            requests[0].request.close_behavior,
1385            ModalCloseBehavior::Manual
1386        );
1387    }
1388
1389    #[test]
1390    #[should_panic(expected = "Dialog requires .content(...)")]
1391    fn dialog_without_content_panics_on_build() {
1392        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1393        tree.add(Dialog::new(lit!("Open dialog")));
1394        tree.layout(SizeProposal::exact(800.0, 600.0));
1395    }
1396}
1397
1398#[cfg(test)]
1399mod scrim_hit_targeting_tests {
1400    use super::*;
1401    use teksilo_canvas::Point;
1402    use teksilo_core::pointer::{EventTime, PointerId, PointerInfo};
1403    use teksilo_core::widget_tree::WidgetTree;
1404    use teksilo_tokens::TargetDensity;
1405
1406    /// A scrim never participates in hit widening: it receives exactly the
1407    /// presses that land on it.
1408    ///
1409    /// A real scrim fills the viewport, and the slop pass's size formula
1410    /// already excludes anything that large by arithmetic. The point of the
1411    /// explicit `no_hit_slop` — and of shrinking the scrim here to make it
1412    /// observable — is that the guarantee must not depend on how big the scrim
1413    /// happens to be.
1414    #[test]
1415    fn a_modal_scrim_never_participates_in_hit_widening() {
1416        use crate::primitives::{FixedSize, ZStack};
1417        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1418        tree.set_input_density(TargetDensity::Touch);
1419        let scrim = tree.add(
1420            FixedSize::new()
1421                .width(20.0)
1422                .height(20.0)
1423                .child(ModalScrim::new().click_to_dismiss(true)),
1424        );
1425        tree.add(ZStack::new().child(scrim));
1426        tree.layout(SizeProposal::exact(200.0, 200.0));
1427
1428        let b = tree.bounds(scrim);
1429        assert_eq!(
1430            b.size(),
1431            Size::new(20.0, 20.0),
1432            "the fixture needs a SMALL scrim"
1433        );
1434        let finger = PointerInfo::touch(PointerId::MOUSE, EventTime::ZERO);
1435        // 4 dp outside, well inside the 8 dp a 20 dp node would otherwise earn
1436        // at Touch. Nothing in the scrim's subtree may claim it.
1437        let probe = Point::new(b.right() + 4.0, b.center().y);
1438        let hit = tree.hit_test_for(probe, &finger);
1439        let claimed_by_scrim = hit.is_some_and(|id| {
1440            std::iter::successors(Some(id), |id| tree.parent(*id)).any(|id| id == scrim)
1441        });
1442        assert!(
1443            !claimed_by_scrim,
1444            "a press outside the scrim reached {hit:?}, inside the scrim subtree"
1445        );
1446    }
1447}