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