Skip to main content

teksilo_widgets/
popover_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `PopoverWidget<T>` — a generic trigger that opens a popover when
5//! activated, plus the [`PopoverButton`] / [`PopoverIconButton`] aliases.
6//!
7//! Wraps a caller-built trigger (`T: PopoverTrigger`) with overlay
8//! wiring: owns a `popover_open: Signal<bool>` toggled on activate /
9//! dismiss, sets `has_popup` and `expanded_when` on the inner trigger so
10//! AT announces the disclosure state, pre-builds the popover content as a
11//! dormant subtree, and shows / hides it via [`OverlayRequest`]. The
12//! `set_dormant` + `activate` + `show_overlay` sequence and the
13//! dismiss-callback shape match [`DateEdit`](crate::date_edit::DateEdit)
14//! so behavior across the disclosure family stays consistent.
15//!
16//! ```rust
17//! # use teksilo_widgets::{Button, ButtonVariant, IconButton, MenuList, MenuItem, PopoverButton, PopoverIconButton};
18//! # use teksilo_widgets::primitives::TextWidget;
19//! # use teksilo_i18n::lit;
20//! // Text trigger (HasPopup::Dialog by default, no caret):
21//! let _w = PopoverButton::new(Button::new(lit!("Choose…")).variant(ButtonVariant::Plain))
22//!     .content(TextWidget::new(lit!("Pick")));
23//!
24//! // Icon trigger (HasPopup::Menu by default, corner caret on):
25//! let _w = PopoverIconButton::new(IconButton::add().toolbar())
26//!     .content(MenuList::new().item(MenuItem::new(lit!("New file"))));
27//! ```
28//!
29//! # Trigger configuration overrides
30//!
31//! `build()` configures the inner trigger by calling `has_popup`,
32//! `expanded_when`, and `on_activate_fn` (and `share_interaction` when a
33//! caret is shown). These **replace** any previous values the caller set
34//! — in particular any `on_activate_fn` set before `::new` is discarded,
35//! because the activate slot is owned by the popover wiring. Use
36//! `on_open` / `on_close`, or observe `open_signal`, for side effects.
37//!
38//! # Per-trigger differences (the `PopoverTrigger` trait)
39//!
40//! `Button` and `IconButton` differ only in: the default `has_popup`
41//! kind, whether the disclosure caret shows by default, whether the
42//! caret is suppressed (IconButton at `Compact`), and how the caret's
43//! color is derived. Those four points live behind `PopoverTrigger`;
44//! everything else is shared by the generic.
45
46use std::rc::Rc;
47use std::time::Duration;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::accesskit::HasPopup;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::overlay::{
54    DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
55};
56use teksilo_core::signal::Signal;
57use teksilo_core::styles::{PopoverStyle, PopoverStyleConfig, PopoverVariant, SharedPopoverStyle};
58use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
59use teksilo_core::widget_id::WidgetId;
60use teksilo_tokens::TextRole;
61
62use crate::button::{Button, InteractionState, resolve_text_role};
63use crate::icon_button::{
64    IconButton, IconButtonSize, resolve_icon_role_embedded, resolve_icon_role_standalone,
65};
66use crate::overlay_trigger::OverlayTrigger;
67use crate::popover_caret::DisclosureCaret;
68use crate::primitives::ZStack;
69
70type OnVoid = Rc<dyn Fn()>;
71
72/// A trigger widget usable with [`PopoverWidget`]. Implemented for
73/// [`Button`] and [`IconButton`]. Captures the few points where the two
74/// triggers differ; everything else is handled by the generic wrapper.
75pub trait PopoverTrigger: Widget + Sized + 'static {
76    /// The `has_popup` kind announced by AT when the caller doesn't
77    /// override it. `Button` → [`HasPopup::Dialog`]; `IconButton` →
78    /// [`HasPopup::Menu`].
79    fn default_has_popup() -> HasPopup;
80
81    /// Whether the disclosure caret is painted by default. `Button` →
82    /// `false` (text buttons advertise via an inline trailing chevron);
83    /// `IconButton` → `true` (icon-only triggers have no label slot).
84    fn default_show_caret() -> bool;
85
86    /// Whether the caret must be suppressed for this trigger regardless
87    /// of the flag (e.g. `IconButton` at `Compact` has no room).
88    /// Default: never suppressed.
89    fn suppress_caret(&self) -> bool {
90        false
91    }
92
93    /// The `TextRole` the disclosure caret tints with, derived from the
94    /// shared interaction signal so the caret and trigger tint together
95    /// across hover / press / focus / disabled. Only called when a caret
96    /// is shown.
97    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole>;
98
99    // The remaining methods delegate to inherent builder methods that
100    // exist on both triggers; they're on the trait so the generic can
101    // call them on a bare `T`.
102
103    /// Share an externally-allocated interaction signal so the caret colour
104    /// tracks the trigger's state (hover / press / focus / disabled) exactly.
105    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self;
106
107    /// Annotate the trigger with the given `has_popup` kind for AT.
108    fn with_has_popup(self, kind: HasPopup) -> Self;
109
110    /// Bind the trigger's `set_expanded` disclosure state to `open`.
111    fn with_expanded_when(self, open: Signal<bool>) -> Self;
112
113    /// Install the popover's open/close handler as the trigger's activate callback.
114    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self;
115
116    /// Return `true` if the trigger already has an activate handler set by
117    /// the caller — the wrapper replaces it and will warn at build time.
118    fn has_on_activate(&self) -> bool;
119}
120
121/// A popover whose trigger is an arbitrary widget, wrapped in
122/// [`OverlayTrigger`].
123///
124/// The third stock shape beside [`PopoverButton`] and [`PopoverIconButton`],
125/// and what replaced the standalone `Popover` widget: that type existed only
126/// because this generic could not take a non-button trigger.
127pub type PopoverCustom = PopoverWidget<OverlayTrigger>;
128
129impl PopoverTrigger for OverlayTrigger {
130    /// A custom trigger opens a panel, not a menu — the same announcement the
131    /// standalone `Popover` made.
132    fn default_has_popup() -> HasPopup {
133        HasPopup::Dialog
134    }
135
136    /// No caret. A caller supplying their own trigger has drawn whatever
137    /// affordance they want; painting a disclosure chevron over it would be the
138    /// framework second-guessing them.
139    fn default_show_caret() -> bool {
140        false
141    }
142
143    fn caret_role(&self, _interaction: &Signal<InteractionState>) -> Signal<TextRole> {
144        // Never consulted while `default_show_caret` is false, and a custom
145        // trigger has no interaction signal of its own to derive a tint from.
146        Signal::new(TextRole::Secondary)
147    }
148
149    fn with_shared_interaction(self, _signal: Signal<InteractionState>) -> Self {
150        // Nothing to share: the caret this exists to tint is not drawn, and an
151        // arbitrary widget has no `InteractionState` the framework can read.
152        self
153    }
154
155    fn with_has_popup(self, kind: HasPopup) -> Self {
156        self.has_popup(kind)
157    }
158
159    fn with_expanded_when(self, open: Signal<bool>) -> Self {
160        self.expanded_when(open)
161    }
162
163    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
164        self.on_activate(f)
165    }
166
167    fn has_on_activate(&self) -> bool {
168        self.has_on_activate()
169    }
170}
171
172impl PopoverTrigger for Button {
173    fn default_has_popup() -> HasPopup {
174        HasPopup::Dialog
175    }
176    fn default_show_caret() -> bool {
177        false
178    }
179    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
180        let variant = self.current_variant();
181        interaction.map(move |s| resolve_text_role(variant, *s))
182    }
183    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
184        self.share_interaction(signal)
185    }
186    fn with_has_popup(self, kind: HasPopup) -> Self {
187        self.has_popup(kind)
188    }
189    fn with_expanded_when(self, open: Signal<bool>) -> Self {
190        self.expanded_when(open)
191    }
192    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
193        self.on_activate_fn(f)
194    }
195    fn has_on_activate(&self) -> bool {
196        self.has_activate_handler()
197    }
198}
199
200impl PopoverTrigger for IconButton {
201    fn default_has_popup() -> HasPopup {
202        HasPopup::Menu
203    }
204    fn default_show_caret() -> bool {
205        true
206    }
207    fn suppress_caret(&self) -> bool {
208        // Compact (22 dp) has no room for the caret without crowding the
209        // icon, and Compact buttons aren't typically menu triggers.
210        matches!(self.size_variant(), IconButtonSize::Compact)
211    }
212    fn caret_role(&self, interaction: &Signal<InteractionState>) -> Signal<TextRole> {
213        if self.is_embedded() {
214            interaction.map(|s| resolve_icon_role_embedded(*s))
215        } else {
216            interaction.map(|s| resolve_icon_role_standalone(*s))
217        }
218    }
219    fn with_shared_interaction(self, signal: Signal<InteractionState>) -> Self {
220        self.share_interaction(signal)
221    }
222    fn with_has_popup(self, kind: HasPopup) -> Self {
223        self.has_popup(kind)
224    }
225    fn with_expanded_when(self, open: Signal<bool>) -> Self {
226        self.expanded_when(open)
227    }
228    fn with_on_activate(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
229        self.on_activate_fn(f)
230    }
231    fn has_on_activate(&self) -> bool {
232        self.has_activate_handler()
233    }
234}
235
236/// One-shot stderr warning when a `PopoverWidget` trigger arrives with an
237/// activate handler that the wrapper will overwrite. Thread-local flag
238/// keeps it from repeating. (Stderr rather than `log::warn!` to avoid a
239/// `log` dependency on teksilo-widgets, matching the crate convention.)
240fn warn_trigger_activate_discarded() {
241    thread_local! {
242        static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
243    }
244    WARNED.with(|w| {
245        if !w.get() {
246            eprintln!(
247                "[teksilo-widgets::popover] PopoverWidget overwrote the trigger's \
248                 on_activate_fn — the caller-set handler was discarded. Use on_open / \
249                 on_close, or observe open_signal, for trigger-side side effects."
250            );
251            w.set(true);
252        }
253    });
254}
255
256/// A trigger paired with a popover surface. See the module docs for the
257/// contract on which trigger properties get overridden during `build()`.
258/// Use the [`PopoverButton`] / [`PopoverIconButton`] aliases for the
259/// concrete trigger types.
260pub struct PopoverWidget<T: PopoverTrigger> {
261    trigger: Option<T>,
262    content: Option<Box<dyn Widget>>,
263
264    popover_open: Signal<bool>,
265    /// Name of the global action that toggles this popover, if the caller asked
266    /// for one. See [`PopoverWidget::open_action`].
267    open_action: Option<&'static str>,
268    placement: OverlayPlacement,
269    dismiss_behavior: DismissBehavior,
270    fade_duration: Option<Duration>,
271    has_popup: HasPopup,
272    show_disclosure_caret: bool,
273
274    on_open: Option<OnVoid>,
275    on_close: Option<OnVoid>,
276
277    /// Which themed [`PopoverStyle`] surface to wrap the content in.
278    /// `Some(variant)` (the default — `PopoverVariant::Default`) routes
279    /// the content through the active popover style so it gets a
280    /// background, border, padding, and shadow for free. `None`
281    /// (`bare()`) adds the content raw — for content that is already
282    /// self-chromed (a `MenuList`, which itself routes through the Menu
283    /// `PopoverStyle`, or a hand-rolled surface `Panel`).
284    surface_variant: Option<PopoverVariant>,
285    /// Per-call style override (highest precedence over the theme slot
286    /// and the built-in `RecipePopoverStyle`). Mirrors `Popover::style`.
287    surface_style: Option<SharedPopoverStyle>,
288    /// Accessible name for the surface's `Role::Dialog` node. Empty by
289    /// default (the wrapped content usually carries its own role/name).
290    surface_name: String,
291
292    content_id: Option<WidgetId>,
293    root_child_id: Option<WidgetId>,
294
295    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
296    /// with the rich / composite slots — every setter clears the other two so
297    /// the last call wins.
298    tooltip_text: Option<teksilo_i18n::LocalizedString>,
299    /// Optional rich tooltip source (registry key or inline content).
300    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
301    /// Optional composite tooltip body (arbitrary widget tree).
302    composite_tooltip_content: Option<Box<dyn Widget>>,
303}
304
305/// A [`Button`] that opens a popover when activated. Alias for
306/// `PopoverWidget<Button>` — `HasPopup::Dialog`, no caret by default.
307pub type PopoverButton = PopoverWidget<Button>;
308
309/// An [`IconButton`] that opens a popover when activated. Alias for
310/// `PopoverWidget<IconButton>` — `HasPopup::Menu`, corner caret on by
311/// default (skipped at `Compact`).
312pub type PopoverIconButton = PopoverWidget<IconButton>;
313
314impl<T: PopoverTrigger> std::fmt::Debug for PopoverWidget<T> {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("PopoverWidget")
317            .field("placement", &self.placement)
318            .field("dismiss_behavior", &self.dismiss_behavior)
319            .field("has_popup", &self.has_popup)
320            .field("show_disclosure_caret", &self.show_disclosure_caret)
321            .field("popover_open", &self.popover_open.get())
322            .finish_non_exhaustive()
323    }
324}
325
326impl<T: PopoverTrigger> PopoverWidget<T> {
327    /// Wrap a pre-configured trigger. The popover content is set
328    /// separately via [`Self::content`] (required).
329    pub fn new(trigger: T) -> Self {
330        Self {
331            trigger: Some(trigger),
332            content: None,
333            popover_open: Signal::new(false),
334            open_action: None,
335            placement: OverlayPlacement::BelowPreferred,
336            dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
337            fade_duration: None,
338            has_popup: T::default_has_popup(),
339            show_disclosure_caret: T::default_show_caret(),
340            on_open: None,
341            on_close: None,
342            surface_variant: Some(PopoverVariant::Default),
343            surface_style: None,
344            surface_name: String::new(),
345            content_id: None,
346            root_child_id: None,
347            tooltip_text: None,
348            rich_tooltip_source: None,
349            composite_tooltip_content: None,
350        }
351    }
352
353    /// Set the popover content — added to the tree as a dormant subtree
354    /// during `build()`, woken via
355    /// [`EventContext::activate`](teksilo_core::widget::EventContext::activate)
356    /// when the trigger fires. Required.
357    pub fn content(mut self, content: impl Widget + 'static) -> Self {
358        self.content = Some(Box::new(content));
359        self
360    }
361
362    /// Override the popover's placement relative to the trigger.
363    /// Default: [`OverlayPlacement::BelowPreferred`].
364    pub fn placement(mut self, p: OverlayPlacement) -> Self {
365        self.placement = p;
366        self
367    }
368
369    /// Override the dismiss behavior. Default:
370    /// [`DismissBehavior::EscapeOrClickOutside`].
371    pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
372        self.dismiss_behavior = b;
373        self
374    }
375
376    /// Animate the overlay in / out over the given duration. Default:
377    /// no fade. See [`OverlayRequest::with_fade`] for the mechanism.
378    pub fn fade_duration(mut self, d: Duration) -> Self {
379        self.fade_duration = Some(d);
380        self
381    }
382
383    /// Override the `has_popup` kind announced by AT. Defaults to the
384    /// trigger type's [`PopoverTrigger::default_has_popup`].
385    pub fn has_popup_kind(mut self, k: HasPopup) -> Self {
386        self.has_popup = k;
387        self
388    }
389
390    /// Whether to paint the disclosure triangle in the trigger's
391    /// bottom-right corner. Defaults to the trigger type's
392    /// [`PopoverTrigger::default_show_caret`]. The caret is
393    /// suppressed automatically when
394    /// [`PopoverTrigger::suppress_caret`] returns `true` (e.g.
395    /// `IconButton` at `Compact`) regardless of this flag. AT-hidden —
396    /// the popup is announced via `set_has_popup` + `set_expanded`.
397    pub fn show_disclosure_caret(mut self, on: bool) -> Self {
398        self.show_disclosure_caret = on;
399        self
400    }
401
402    /// Notification fired on the rising edge of the popover (after the
403    /// overlay show request is dispatched). No `EventContext` — observe
404    /// [`Self::open_signal`] from your `build()` if you need
405    /// frame / dispatch context.
406    pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
407        self.on_open = Some(Rc::new(f));
408        self
409    }
410
411    /// Notification fired on the falling edge of the popover (when the
412    /// overlay's dismiss callback runs).
413    pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
414        self.on_close = Some(Rc::new(f));
415        self
416    }
417
418    /// Observe-only handle to the popover-open state.
419    ///
420    /// **Read-back only — writing this does not open the popover.** Presenting
421    /// an overlay needs an `EventContext` (`show_overlay` + `request_focus`),
422    /// which no signal observer has; this field is the mirror the trigger writes
423    /// after it has done that work. To open the popover from somewhere other
424    /// than its trigger, use [`open_action`](Self::open_action).
425    pub fn open_signal(&self) -> Signal<bool> {
426        self.popover_open.clone()
427    }
428
429    /// Register a **named global action** that toggles this popover, so a menu
430    /// entry, a global shortcut or `ctx.send_intent(...)` can open it — not only
431    /// a click on its own trigger.
432    ///
433    /// Without this a popover is reachable by pointer alone. `on_open` /
434    /// `on_close` are notification-only and `open_signal` is a read-back mirror
435    /// (see its doc), so an app that wanted "Go to… ⌘G" next to its button had
436    /// no way to wire the second half. Action handlers are the one place that
437    /// *does* get an `EventContext`, which is exactly what presenting an overlay
438    /// requires — so the action runs the identical toggle the trigger runs, and
439    /// the two can never drift.
440    ///
441    /// Registered with `register_action_global`, deliberately: intents walk
442    /// source-widget → root, and a menu renders in an **overlay** that is a
443    /// sibling of the popover's own subtree, so a plain `register_action` would
444    /// never be reached from a menu item. Pair it with
445    /// `register_shortcut_global` in the app for the keystroke.
446    ///
447    /// ```ignore
448    /// PopoverButton::new(Button::new(tr!(go_to())))
449    ///     .content(palette)
450    ///     .open_action("go.to")
451    /// // elsewhere: MenuEntry::new(tr!(go_to())).intent("go.to").shortcut("go.to")
452    /// ```
453    pub fn open_action(mut self, intent: &'static str) -> Self {
454        self.open_action = Some(intent);
455        self
456    }
457
458    /// Choose which themed [`PopoverVariant`] surface wraps the content.
459    /// Default is [`PopoverVariant::Default`] (elevated panel with
460    /// padding + shadow). The surface is resolved from the active
461    /// [`PopoverStyle`] (`theme.style_slots.popover`), so it themes
462    /// app-wide.
463    pub fn surface(mut self, variant: PopoverVariant) -> Self {
464        self.surface_variant = Some(variant);
465        self
466    }
467
468    /// Opt OUT of the themed surface: the content is added raw, with no
469    /// background / border / padding. Use when the content already
470    /// supplies its own chrome — a [`MenuList`](crate::MenuList) (which
471    /// routes through the Menu `PopoverStyle` itself) or a hand-rolled
472    /// surface `Panel`. Without this, such content would be
473    /// double-chromed.
474    pub fn bare(mut self) -> Self {
475        self.surface_variant = None;
476        self
477    }
478
479    /// Per-call [`PopoverStyle`] override for the surface (highest
480    /// precedence over the theme slot and the built-in default). Mirrors
481    /// the per-call override the standalone `Popover` used to offer. No effect under
482    /// [`bare`](Self::bare).
483    pub fn surface_style(mut self, style: impl PopoverStyle) -> Self {
484        self.surface_style = Some(Rc::new(style));
485        self
486    }
487
488    /// Accessible name for the surface's `Role::Dialog` node. Defaults
489    /// to empty (the wrapped content usually carries its own role and
490    /// name). No effect under [`bare`](Self::bare) or for the Menu
491    /// variant (which is presentational).
492    pub fn surface_name(mut self, name: impl Into<String>) -> Self {
493        self.surface_name = name.into();
494        self
495    }
496
497    /// Show a plain single-line tooltip on the trigger after a hover delay.
498    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
499    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
500    /// [`composite_tooltip`](Self::composite_tooltip) — each setter clears
501    /// the other three so the last call wins. The tooltip anchors on the
502    /// trigger, not on the popover content.
503    pub fn tooltip(mut self, text: impl Into<teksilo_i18n::LocalizedString>) -> Self {
504        self.tooltip_text = Some(text.into());
505        self.rich_tooltip_source = None;
506        self.composite_tooltip_content = None;
507        self
508    }
509
510    /// Show a rich tooltip (looked up by registry key) on the trigger after
511    /// a hover delay. Mutually exclusive with the other tooltip setters —
512    /// the last call wins.
513    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
514        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
515        self.tooltip_text = None;
516        self.composite_tooltip_content = None;
517        self
518    }
519
520    /// Show an inline rich tooltip (pre-built [`TooltipContent`]) on the
521    /// trigger after a hover delay. Mutually exclusive with the other tooltip
522    /// setters — the last call wins.
523    ///
524    /// [`TooltipContent`]: crate::tooltip::TooltipContent
525    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
526        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
527        self.tooltip_text = None;
528        self.composite_tooltip_content = None;
529        self
530    }
531
532    /// Show a composite tooltip (arbitrary widget tree) on the trigger after
533    /// a longer hover delay. Mutually exclusive with the other tooltip setters
534    /// — the last call wins.
535    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
536        self.composite_tooltip_content = Some(Box::new(content));
537        self.tooltip_text = None;
538        self.rich_tooltip_source = None;
539        self
540    }
541}
542
543/// The popover's panel: the caller's content, wrapped in the themed surface.
544///
545/// A widget of its own so the whole thing — surface included — can sit behind a
546/// [`DeferredSubtree`](teksilo_core::deferred_subtree::DeferredSubtree) and be
547/// built the first time the popover is opened. It was inline in
548/// `PopoverWidget::build` until then, which meant every popover built its panel
549/// whether or not anyone ever opened it, on every rebuild of its owner. In a
550/// virtualized table that is once per row per rebuild; see `DeferredSubtree`
551/// for the measurement.
552struct PopoverBody {
553    content: Option<Box<dyn Widget>>,
554    surface_variant: Option<teksilo_core::styles::PopoverVariant>,
555    surface_style: Option<SharedPopoverStyle>,
556    surface_name: String,
557    placement: OverlayPlacement,
558    body_id: Option<WidgetId>,
559}
560
561impl std::fmt::Debug for PopoverBody {
562    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        f.debug_struct("PopoverBody").finish()
564    }
565}
566
567impl Widget for PopoverBody {
568    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
569        if let Some(id) = self.body_id {
570            return vec![id];
571        }
572        let Some(content) = self.content.take() else {
573            return Vec::new();
574        };
575        // Materialize the inner content first so the surface style sees a ready
576        // WidgetId (same pattern as the `Popover` widget).
577        let inner_content_id = ctx.add_boxed(content);
578
579        // Wrap the inner content in the themed popover surface (background,
580        // border, padding, shadow) unless the caller opted out with `bare()`.
581        // The surface is resolved per-call > theme slot > built-in
582        // `RecipePopoverStyle`, so popovers theme app-wide via
583        // `theme.style_slots.popover`.
584        let id = match self.surface_variant {
585            None => inner_content_id,
586            Some(variant) => {
587                let style: SharedPopoverStyle = self
588                    .surface_style
589                    .clone()
590                    .or_else(|| ctx.theme().style_slots.popover.clone())
591                    .unwrap_or_else(|| Rc::new(crate::styles::RecipePopoverStyle::default()));
592                let cfg = PopoverStyleConfig {
593                    content: inner_content_id,
594                    variant,
595                    name: self.surface_name.clone(),
596                    placement: self.placement.clone(),
597                    show_caret: false,
598                    caret_size: 0.0,
599                };
600                style.make_body(&cfg, ctx)
601            }
602        };
603        self.body_id = Some(id);
604        vec![id]
605    }
606
607    fn layout_response(
608        &self,
609        proposal: SizeProposal,
610        ctx: &LayoutContext,
611    ) -> teksilo_core::widget::LayoutResponse {
612        match self.body_id {
613            Some(id) => ctx
614                .child_size(id, proposal)
615                .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
616                .into(),
617            None => Size::new(0.0, 0.0).into(),
618        }
619    }
620
621    fn place_children(
622        &self,
623        bounds: Rect,
624        _proposal: SizeProposal,
625        children: &mut [WidgetPlacement],
626        _ctx: &LayoutContext,
627    ) {
628        for child in children.iter_mut() {
629            child.origin = Point::new(bounds.x, bounds.y);
630            child.size = bounds.size();
631        }
632    }
633
634    fn preserves_children_on_rebuild(&self) -> bool {
635        true
636    }
637}
638
639impl<T: PopoverTrigger> Widget for PopoverWidget<T> {
640    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
641        let content = self
642            .content
643            .take()
644            .expect("PopoverWidget::content(...) was not set");
645        // The panel — content *and* the surface around it — is built the first
646        // time the popover is opened, not here. The id below is a real node
647        // from this moment, so everything downstream (dormant / gated / shown /
648        // returned-as-child / dismissed) is unchanged; only when the subtree
649        // under it exists has moved. `materialize_now` in the open handler
650        // closes it up before the overlay is placed and focus moves in.
651        let content_id = ctx.add_deferred(
652            self.popover_open.clone(),
653            PopoverBody {
654                content: Some(content),
655                surface_variant: self.surface_variant,
656                surface_style: self.surface_style.clone(),
657                surface_name: self.surface_name.clone(),
658                placement: self.placement.clone(),
659                body_id: None,
660            },
661        );
662        // Focus targets the panel; `request_focus` walks to its first focusable
663        // descendant, so it still lands inside the chrome rather than on it.
664        let focus_id = content_id;
665        ctx.set_dormant(content_id);
666        // Gate the content's activation on `popover_open` so it is the single
667        // source of truth. Without this, when the PopoverWidget itself is woken
668        // by an ancestor's `visible_when` re-activation (e.g. a Toolbar overflow
669        // chevron appearing), the activation cascade would wake the dormant
670        // content in-tree — its rows would "float" outside the (closed) popover.
671        // The per-pass visibility reconciliation keeps the content dormant
672        // whenever the popover is closed, and `arena.activate` skips it in the
673        // cascade because its gate is `false`.
674        ctx.visible_when(content_id, self.popover_open.clone());
675        self.content_id = Some(content_id);
676
677        let trigger = self
678            .trigger
679            .take()
680            .expect("PopoverWidget trigger missing (build() called twice?)");
681
682        // The wrapper owns the trigger's activate slot (it opens the
683        // popover), so any caller-set `on_activate_fn` is about to be
684        // discarded. That is documented but easy to do by accident — make
685        // it loud. Use `on_open` / `on_close` (or observe `open_signal`)
686        // for trigger-side side effects instead.
687        if trigger.has_on_activate() {
688            debug_assert!(
689                false,
690                "PopoverWidget: the trigger's on_activate_fn is overwritten by the popover \
691                 wiring and will be discarded; use on_open / on_close instead"
692            );
693            warn_trigger_activate_discarded();
694        }
695
696        let want_caret = self.show_disclosure_caret && !trigger.suppress_caret();
697
698        let popover_open = self.popover_open.clone();
699        let self_ref = ctx.self_id();
700        let placement = self.placement.clone();
701        let dismiss_behavior = self.dismiss_behavior.clone();
702        let fade_duration = self.fade_duration;
703        let on_open = self.on_open.clone();
704        let on_close = self.on_close.clone();
705
706        // Dismiss callback — runs when the overlay manager closes the
707        // overlay (Escape, click-outside, or explicit dismiss). Flips
708        // popover_open and fires the user's on_close. No `EventContext`
709        // available here, so on_close is `Fn()`.
710        let dismiss_cb: OverlayDismissCallback = {
711            let popover_open = popover_open.clone();
712            let on_close = on_close.clone();
713            Rc::new(move || {
714                popover_open.set(false);
715                if let Some(cb) = on_close.as_ref() {
716                    cb();
717                }
718            })
719        };
720
721        // Activate handler installed onto the trigger. Toggles the
722        // popover: if open, dismiss; if closed, wake the dormant content,
723        // request the overlay, and move focus into it.
724        //
725        // Built as an `Rc` so `open_action` can register the *same* closure as a
726        // named global action. Sharing it (rather than writing a second, similar
727        // one) is the point: a menu entry and the trigger must not be able to
728        // disagree about what opening this popover means.
729        let activate: Rc<dyn Fn(&mut EventContext)> = Rc::new({
730            let popover_open = popover_open.clone();
731            let dismiss_cb = dismiss_cb.clone();
732            let on_open = on_open.clone();
733            move |ctx_evt: &mut EventContext| {
734                if popover_open.get() {
735                    popover_open.set(false);
736                    ctx_evt.dismiss_all_except_hosts();
737                } else {
738                    popover_open.set(true);
739                    // Build the panel if this is its first open, before the
740                    // overlay below is measured against it and before focus
741                    // moves into it — both happen in this same drain.
742                    ctx_evt.materialize_now(content_id);
743                    ctx_evt.activate(content_id);
744                    let mut req = OverlayRequest {
745                        content_id,
746                        anchor: self_ref,
747                        placement: placement.clone(),
748                        dismiss: dismiss_behavior.clone(),
749                        layer: OverlayLayer::InTree,
750                        parent_overlay: None,
751                        on_dismiss: Some(dismiss_cb.clone()),
752                        fade_duration: None,
753                    };
754                    if let Some(d) = fade_duration {
755                        req = req.with_fade(d);
756                    }
757                    ctx_evt.show_overlay(req);
758                    ctx_evt.request_focus(focus_id);
759                    if let Some(cb) = on_open.as_ref() {
760                        cb();
761                    }
762                }
763            }
764        });
765
766        // The named-action door. Registered global, not local: intents walk
767        // source-widget → root, and a menu renders in an overlay that is a
768        // sibling of this widget's subtree, so a plain `register_action` would
769        // never be reached from a menu item.
770        if let Some(intent) = self.open_action {
771            let act = activate.clone();
772            ctx.register_action_global(
773                teksilo_core::action::Action::new(intent)
774                    .on_invoke(move |_intent, ctx_evt| act(ctx_evt)),
775            );
776        }
777
778        // With a caret, allocate the interaction signal up-front and
779        // share it with the trigger so the caret's color tracks the
780        // trigger's exactly. Without a caret, the trigger allocates its
781        // own signal as before.
782        if want_caret {
783            let interaction = ctx.signal(InteractionState::Idle);
784            let role_signal = trigger.caret_role(&interaction);
785            let trigger = trigger
786                .with_shared_interaction(interaction)
787                .with_has_popup(self.has_popup)
788                .with_expanded_when(popover_open.clone())
789                .with_on_activate({
790                    let act = activate.clone();
791                    move |c: &mut EventContext| act(c)
792                });
793            let trigger_id = ctx.add(trigger);
794            let caret_id = ctx.add(DisclosureCaret { role: role_signal });
795            let root_id = ctx.add(ZStack::new().add_child(trigger_id).add_child(caret_id));
796            self.root_child_id = Some(root_id);
797            if let Some(content) = self.composite_tooltip_content.take() {
798                let delay = ctx.theme().motion.tooltip_delay_heavy;
799                crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
800            } else if let Some(source) = self.rich_tooltip_source.clone() {
801                let delay = ctx.theme().motion.tooltip_delay;
802                crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
803            } else if let Some(text) = self.tooltip_text.clone() {
804                let delay = ctx.theme().motion.tooltip_delay;
805                crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
806            }
807            // Return BOTH the trigger root AND the dormant content as
808            // children so the framework links content_id under this
809            // widget in the arena. Without this, content_id stays an
810            // orphan root and `arena.hit_test_at` walks its subtree on
811            // every click (descendants added during the content's own
812            // build can re-surface as hit targets at their pre-dormant
813            // positions). The layout pass skips dormant children.
814            return vec![root_id, content_id];
815        }
816
817        let trigger = trigger
818            .with_has_popup(self.has_popup)
819            .with_expanded_when(popover_open.clone())
820            .with_on_activate(move |c: &mut EventContext| activate(c));
821        let trigger_id = ctx.add(trigger);
822        self.root_child_id = Some(trigger_id);
823        if let Some(content) = self.composite_tooltip_content.take() {
824            let delay = ctx.theme().motion.tooltip_delay_heavy;
825            crate::tooltip::attach_composite_tooltip_boxed(ctx, trigger_id, content, delay);
826        } else if let Some(source) = self.rich_tooltip_source.clone() {
827            let delay = ctx.theme().motion.tooltip_delay;
828            crate::tooltip::attach_rich_tooltip_source(ctx, trigger_id, source, delay);
829        } else if let Some(text) = self.tooltip_text.clone() {
830            let delay = ctx.theme().motion.tooltip_delay;
831            crate::tooltip::attach_plain_tooltip(ctx, trigger_id, text, delay);
832        }
833        // See the disclosure-caret branch for the content-linking rationale.
834        vec![trigger_id, content_id]
835    }
836
837    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
838        match self.root_child_id {
839            Some(id) => ctx
840                .child_layout_response(id, proposal)
841                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
842            None => proposal.resolve(0.0, 0.0).into(),
843        }
844    }
845
846    fn place_children(
847        &self,
848        bounds: Rect,
849        _proposal: SizeProposal,
850        children: &mut [WidgetPlacement],
851        _ctx: &LayoutContext,
852    ) {
853        // The trigger fills our bounds; the dormant/active content never
854        // participates in trigger layout — its bounds are owned by the
855        // overlay manager when shown and stay at zero while dormant.
856        // Dormant children are already filtered out before placements
857        // reach here; if the content is active (popover open), zero its
858        // placement so the parent's bounds don't clobber overlay
859        // positioning.
860        for child in children.iter_mut() {
861            if Some(child.id) == self.content_id {
862                child.size = teksilo_canvas::Size::ZERO;
863                continue;
864            }
865            child.origin = bounds.origin();
866            child.size = bounds.size();
867        }
868    }
869
870    fn children(&self) -> Vec<WidgetId> {
871        // Include both the trigger root AND the dormant content so
872        // `set_dormant` cascades correctly and `arena.hit_test_at` can
873        // prune the content subtree when it's not visible.
874        let mut out = Vec::new();
875        if let Some(id) = self.root_child_id {
876            out.push(id);
877        }
878        if let Some(id) = self.content_id {
879            out.push(id);
880        }
881        out
882    }
883
884    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
885        // No AT presence of our own — the inner trigger declares
886        // `Role::Button`, `set_has_popup`, and `set_expanded`; the popover
887        // content advertises its own role / live region. The disclosure
888        // caret is decorative (set_hidden in its own accessibility()).
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use crate::primitives::{MinSize, RectWidget};
896    use teksilo_canvas::Point;
897    use teksilo_core::accesskit::{HasPopup, Role};
898    use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
899    use teksilo_core::widget_tree::WidgetTree;
900    use teksilo_i18n::lit;
901
902    fn light_tree() -> WidgetTree {
903        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
904    }
905
906    fn dummy_content() -> impl Widget {
907        MinSize::new(40.0, 40.0).child(RectWidget::new())
908    }
909
910    // ── PopoverButton (text trigger) ────────────────────────────────
911
912    #[test]
913    #[should_panic(expected = "PopoverWidget::content")]
914    fn button_panics_without_content() {
915        let mut tree = light_tree();
916        tree.add(PopoverButton::new(Button::new(lit!("Open"))));
917        tree.layout(SizeProposal::exact(300.0, 80.0));
918    }
919
920    #[test]
921    fn button_trigger_announces_role_and_haspopup_dialog() {
922        let mut tree = light_tree();
923        tree.add(PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content()));
924        tree.layout(SizeProposal::exact(300.0, 80.0));
925        let update = tree.sync_accessibility();
926        let button_node = update
927            .nodes
928            .iter()
929            .find(|(_, n)| n.role() == Role::Button)
930            .map(|(_, n)| n)
931            .expect("button node");
932        assert_eq!(
933            button_node.has_popup(),
934            Some(HasPopup::Dialog),
935            "PopoverButton default has_popup must be Dialog",
936        );
937        assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
938    }
939
940    #[test]
941    fn button_enter_opens_popover_and_flips_open_signal() {
942        let mut tree = light_tree();
943        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(dummy_content());
944        let open_signal = pb.open_signal();
945        let id = tree.add(pb);
946        tree.layout(SizeProposal::exact(300.0, 80.0));
947        let button_id = tree
948            .first_focusable_descendant(id)
949            .expect("PopoverButton must expose a focusable inner Button");
950        tree.focus(button_id);
951        assert!(!open_signal.get());
952        tree.dispatch_event(WidgetEvent::KeyDown {
953            key: Key::Enter,
954            modifiers: Modifiers::NONE,
955            text: None,
956        });
957        tree.dispatch_event(WidgetEvent::KeyUp {
958            key: Key::Enter,
959            modifiers: Modifiers::NONE,
960        });
961        assert!(open_signal.get(), "Enter should open the popover");
962    }
963
964    /// `open_action` opens the popover from a **sibling** widget's intent.
965    ///
966    /// The sibling placement is the test, not incidental scenery: intents walk
967    /// source-widget → root, so a locally-registered action would never be
968    /// reached from a menu — which renders in an overlay that is a sibling of
969    /// the popover's subtree, exactly like this button. Firing from a child of
970    /// the popover would pass with either registration and prove nothing.
971    #[test]
972    fn open_action_opens_the_popover_from_a_sibling_intent() {
973        use crate::primitives::VStack;
974        use teksilo_core::intent::Intent;
975
976        let mut tree = light_tree();
977        let pb = PopoverButton::new(Button::new(lit!("Open")))
978            .content(dummy_content())
979            .open_action("test.open");
980        let open_signal = pb.open_signal();
981        let pb_id = tree.add(pb);
982        let fire_id = tree.add(
983            Button::new(lit!("Fire"))
984                .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.open"))),
985        );
986        tree.add(VStack::new().add_child(pb_id).add_child(fire_id));
987        tree.layout(SizeProposal::exact(300.0, 160.0));
988
989        assert!(!open_signal.get(), "starts closed");
990
991        let fire_btn = tree.first_focusable_descendant(fire_id).unwrap_or(fire_id);
992        tree.focus(fire_btn);
993        tree.dispatch_event(WidgetEvent::KeyDown {
994            key: Key::Enter,
995            modifiers: Modifiers::NONE,
996            text: None,
997        });
998        tree.dispatch_event(WidgetEvent::KeyUp {
999            key: Key::Enter,
1000            modifiers: Modifiers::NONE,
1001        });
1002        assert!(
1003            open_signal.get(),
1004            "the named action must open the popover from off its own subtree"
1005        );
1006    }
1007
1008    /// The action *toggles*, sharing one closure with the trigger — so a menu
1009    /// entry and a click can never disagree about what the popover does.
1010    ///
1011    /// Fired from **inside** the panel, which is the only place the toggle's
1012    /// close branch is still reachable. A sibling cannot reach it: taking focus
1013    /// away from an open popover now dismisses it (non-modal overlays follow
1014    /// focus out rather than trapping it), so by the time an outside control is
1015    /// focused enough to be activated, there is nothing left to close and the
1016    /// shared closure correctly takes its *open* branch. That is not new
1017    /// asymmetry — the popover's default `EscapeOrClickOutside` already meant a
1018    /// real pointer click on that sibling dismissed it before activating. The
1019    /// keyboard simply stopped disagreeing with the mouse.
1020    /// `open_action_opens_the_popover_from_a_sibling_intent` above still pins
1021    /// the global-registration half.
1022    #[test]
1023    fn open_action_toggles_rather_than_only_opening() {
1024        use teksilo_core::intent::Intent;
1025
1026        let mut tree = light_tree();
1027        let pb = PopoverButton::new(Button::new(lit!("Open")))
1028            .content(
1029                Button::new(lit!("Fire"))
1030                    .on_activate_fn(|ctx| ctx.send_intent(Intent::new("test.toggle"))),
1031            )
1032            .open_action("test.toggle");
1033        let open_signal = pb.open_signal();
1034        let pb_id = tree.add(pb);
1035        tree.layout(SizeProposal::exact(300.0, 160.0));
1036
1037        let trigger = tree
1038            .first_focusable_descendant(pb_id)
1039            .expect("the trigger is the only focusable while closed");
1040        tree.focus(trigger);
1041        let enter = |tree: &mut WidgetTree| {
1042            tree.dispatch_event(WidgetEvent::KeyDown {
1043                key: Key::Enter,
1044                modifiers: Modifiers::NONE,
1045                text: None,
1046            });
1047            tree.dispatch_event(WidgetEvent::KeyUp {
1048                key: Key::Enter,
1049                modifiers: Modifiers::NONE,
1050            });
1051        };
1052
1053        enter(&mut tree);
1054        assert!(open_signal.get(), "first fire opens");
1055        // Opening moved focus into the panel, onto its own Fire button — so the
1056        // next Enter runs the same shared closure without focus ever leaving.
1057        enter(&mut tree);
1058        assert!(!open_signal.get(), "second fire closes");
1059    }
1060
1061    /// **A popover that is never opened never builds its panel** — and keeps
1062    /// not building it however often its owner rebuilds.
1063    ///
1064    /// This is the regression guard for the reason `DeferredSubtree` exists. A
1065    /// `PopoverIconButton` in a virtualized table is constructed once per
1066    /// visible row per rebuild; building each one's menu was measured at ~85%
1067    /// of the whole table's rebuild cost. The panel here counts its own builds,
1068    /// so "parked dormant" cannot pass for "not built".
1069    #[test]
1070    fn an_unopened_popover_never_builds_its_panel() {
1071        use teksilo_core::signal::Signal;
1072
1073        #[derive(Debug)]
1074        struct CountingContent {
1075            builds: Signal<u32>,
1076        }
1077        impl Widget for CountingContent {
1078            fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1079                self.builds.set(self.builds.get() + 1);
1080                Vec::new()
1081            }
1082            fn layout_response(
1083                &self,
1084                p: SizeProposal,
1085                _c: &teksilo_core::widget::LayoutContext,
1086            ) -> teksilo_core::widget::LayoutResponse {
1087                p.resolve(40.0, 20.0).into()
1088            }
1089        }
1090
1091        /// The owner: rebuilds on demand and constructs a **fresh**
1092        /// `PopoverButton` each time, which is what a virtualized table's cell
1093        /// delegate does. Constructing the widget value is nearly free; adding
1094        /// its panel to the arena is what used to cost.
1095        #[derive(Debug)]
1096        struct Owner {
1097            builds: Signal<u32>,
1098            open_out: Signal<Option<Signal<bool>>>,
1099            child: Option<WidgetId>,
1100        }
1101        impl Widget for Owner {
1102            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1103                let pb = PopoverButton::new(Button::new(lit!("Open"))).content(CountingContent {
1104                    builds: self.builds.clone(),
1105                });
1106                self.open_out.set(Some(pb.open_signal()));
1107                let id = ctx.add(pb);
1108                self.child = Some(id);
1109                vec![id]
1110            }
1111            fn layout_response(
1112                &self,
1113                p: SizeProposal,
1114                c: &teksilo_core::widget::LayoutContext,
1115            ) -> teksilo_core::widget::LayoutResponse {
1116                self.child
1117                    .and_then(|id| c.child_size(id, p))
1118                    .unwrap_or_else(|| p.resolve(0.0, 0.0))
1119                    .into()
1120            }
1121        }
1122
1123        let builds = Signal::new(0);
1124        let open_out = Signal::new(None);
1125        let mut tree = light_tree();
1126        let owner = tree.add(Owner {
1127            builds: builds.clone(),
1128            open_out: open_out.clone(),
1129            child: None,
1130        });
1131        tree.layout(SizeProposal::exact(300.0, 120.0));
1132        assert_eq!(builds.get(), 0, "the panel was built without being opened");
1133
1134        // Rebuild the owner repeatedly — one fresh popover per pass, exactly as
1135        // a table cell produces one per row per rebuild.
1136        for _ in 0..5 {
1137            tree.arena_mark_needs_rebuild_for_testing(owner);
1138            tree.layout(SizeProposal::exact(300.0, 120.0));
1139        }
1140        assert_eq!(
1141            builds.get(),
1142            0,
1143            "rebuilding the owner dragged five unopened panels into the arena"
1144        );
1145
1146        // Opening builds it — once — and it survives a close/reopen, so
1147        // whatever state the panel holds is not thrown away.
1148        let open = open_out.get().expect("the popover published its signal");
1149        let button = tree
1150            .first_focusable_descendant(owner)
1151            .expect("focusable inner Button");
1152        let enter = move |t: &mut WidgetTree| {
1153            // Re-aim at the trigger each time: opening moves focus into the
1154            // panel, and this panel has no focusable control of its own.
1155            t.focus(button);
1156            t.dispatch_event(WidgetEvent::KeyDown {
1157                key: Key::Enter,
1158                modifiers: Modifiers::NONE,
1159                text: None,
1160            });
1161            t.dispatch_event(WidgetEvent::KeyUp {
1162                key: Key::Enter,
1163                modifiers: Modifiers::NONE,
1164            });
1165            t.layout(SizeProposal::exact(300.0, 120.0));
1166        };
1167        enter(&mut tree);
1168        assert!(open.get(), "Enter should open the popover");
1169        assert_eq!(builds.get(), 1, "opening must build the panel");
1170
1171        // Close and reopen. Driven through the widget's own open signal rather
1172        // than a second keystroke: opening moved focus into the panel, and
1173        // routing a key back out of it is a different guarantee, covered by
1174        // `shared_open_closure_fires_from_trigger_and_from_inside_the_panel`.
1175        open.set(false);
1176        tree.layout(SizeProposal::exact(300.0, 120.0));
1177        open.set(true);
1178        tree.layout(SizeProposal::exact(300.0, 120.0));
1179        assert_eq!(
1180            builds.get(),
1181            1,
1182            "reopening rebuilt the panel — its state would have been lost"
1183        );
1184    }
1185
1186    #[test]
1187    fn default_wraps_content_in_themed_surface_bare_does_not() {
1188        // A pure-leaf content (RectWidget has no children) makes the wrapping
1189        // observable: the default surface puts one more node between the
1190        // overlay's content id and that leaf than `bare()` does.
1191        //
1192        // Asserted as a *difference* rather than as an absolute depth on
1193        // purpose. The panel is now built behind a `DeferredSubtree` (so an
1194        // unopened popover costs nothing), which puts two layout-transparent
1195        // wrappers above it; pinning the exact chain would make this test a
1196        // record of how many wrappers there happen to be rather than of the
1197        // thing it is named after.
1198        fn open_overlay_content(bare: bool) -> (WidgetTree, WidgetId) {
1199            let mut tree = light_tree();
1200            let mut pb = PopoverButton::new(Button::new(lit!("Open"))).content(RectWidget::new());
1201            if bare {
1202                pb = pb.bare();
1203            }
1204            let open = pb.open_signal();
1205            let id = tree.add(pb);
1206            tree.layout(SizeProposal::exact(300.0, 120.0));
1207            let button = tree
1208                .first_focusable_descendant(id)
1209                .expect("focusable inner Button");
1210            tree.focus(button);
1211            tree.dispatch_event(WidgetEvent::KeyDown {
1212                key: Key::Enter,
1213                modifiers: Modifiers::NONE,
1214                text: None,
1215            });
1216            tree.dispatch_event(WidgetEvent::KeyUp {
1217                key: Key::Enter,
1218                modifiers: Modifiers::NONE,
1219            });
1220            assert!(open.get(), "Enter should open the popover");
1221            tree.layout(SizeProposal::exact(300.0, 120.0));
1222            let content = tree
1223                .overlay_manager()
1224                .active_content_ids()
1225                .first()
1226                .copied()
1227                .expect("an active overlay content");
1228            (tree, content)
1229        }
1230
1231        /// Steps from `id` down to the first node with no children.
1232        fn depth_to_leaf(tree: &WidgetTree, id: WidgetId) -> usize {
1233            let mut depth = 0;
1234            let mut cur = id;
1235            loop {
1236                let kids = tree.children(cur);
1237                match kids.first() {
1238                    Some(&next) => {
1239                        depth += 1;
1240                        cur = next;
1241                    }
1242                    None => return depth,
1243                }
1244            }
1245        }
1246
1247        let (tree_def, c_def) = open_overlay_content(false);
1248        let (tree_bare, c_bare) = open_overlay_content(true);
1249        let deep = depth_to_leaf(&tree_def, c_def);
1250        let bare = depth_to_leaf(&tree_bare, c_bare);
1251        assert_eq!(
1252            deep,
1253            bare + 1,
1254            "the default surface must add exactly one node of chrome that bare() \
1255             does not (default {deep}, bare {bare})"
1256        );
1257    }
1258
1259    #[test]
1260    fn button_caret_does_not_break_pointer_clicks() {
1261        // The disclosure caret is layered on top of the trigger in a
1262        // ZStack; it must be pointer-pass-through so mouse clicks reach
1263        // the trigger. Aim at the bottom-right quadrant where it paints.
1264        let mut tree = light_tree();
1265        let pb = PopoverButton::new(Button::new(lit!("Open")))
1266            .show_disclosure_caret(true)
1267            .content(dummy_content());
1268        let open_signal = pb.open_signal();
1269        let id = tree.add(pb);
1270        tree.layout(SizeProposal::exact(300.0, 80.0));
1271        let trigger_id = tree
1272            .first_focusable_descendant(id)
1273            .expect("must expose a focusable inner Button");
1274        let b = tree.bounds(trigger_id);
1275        let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1276        tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1277        tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1278        assert!(
1279            open_signal.get(),
1280            "click on the caret quadrant must pass through to the trigger",
1281        );
1282    }
1283
1284    // ── PopoverIconButton (icon trigger) ────────────────────────────
1285
1286    #[test]
1287    #[should_panic(expected = "PopoverWidget::content")]
1288    fn icon_panics_without_content() {
1289        let mut tree = light_tree();
1290        tree.add(PopoverIconButton::new(IconButton::add()));
1291        tree.layout(SizeProposal::exact(300.0, 80.0));
1292    }
1293
1294    #[test]
1295    fn icon_trigger_announces_haspopup_menu_collapsed() {
1296        let mut tree = light_tree();
1297        tree.add(PopoverIconButton::new(IconButton::add()).content(dummy_content()));
1298        tree.layout(SizeProposal::exact(300.0, 80.0));
1299        let update = tree.sync_accessibility();
1300        let button_node = update
1301            .nodes
1302            .iter()
1303            .find(|(_, n)| n.role() == Role::Button)
1304            .map(|(_, n)| n)
1305            .expect("button node");
1306        assert_eq!(
1307            button_node.has_popup(),
1308            Some(HasPopup::Menu),
1309            "PopoverIconButton default has_popup must be Menu",
1310        );
1311        assert_eq!(button_node.is_expanded(), Some(false), "starts collapsed");
1312    }
1313
1314    #[test]
1315    fn icon_enter_opens_popover_and_flips_open_signal() {
1316        let mut tree = light_tree();
1317        let pib = PopoverIconButton::new(IconButton::add()).content(dummy_content());
1318        let open_signal = pib.open_signal();
1319        let id = tree.add(pib);
1320        tree.layout(SizeProposal::exact(300.0, 80.0));
1321        let button_id = tree
1322            .first_focusable_descendant(id)
1323            .expect("must expose a focusable inner IconButton");
1324        tree.focus(button_id);
1325        assert!(!open_signal.get());
1326        tree.dispatch_event(WidgetEvent::KeyDown {
1327            key: Key::Enter,
1328            modifiers: Modifiers::NONE,
1329            text: None,
1330        });
1331        tree.dispatch_event(WidgetEvent::KeyUp {
1332            key: Key::Enter,
1333            modifiers: Modifiers::NONE,
1334        });
1335        assert!(open_signal.get(), "Enter should open the popover");
1336    }
1337
1338    #[test]
1339    fn icon_caret_false_still_focusable() {
1340        let mut tree = light_tree();
1341        let id = tree.add(
1342            PopoverIconButton::new(IconButton::add())
1343                .show_disclosure_caret(false)
1344                .content(dummy_content()),
1345        );
1346        tree.layout(SizeProposal::exact(300.0, 80.0));
1347        let _ = tree
1348            .first_focusable_descendant(id)
1349            .expect("focusable IconButton must still be present");
1350    }
1351
1352    #[test]
1353    fn icon_caret_click_through_reaches_trigger() {
1354        let mut tree = light_tree();
1355        let pib = PopoverIconButton::new(IconButton::add().toolbar()).content(dummy_content());
1356        let open_signal = pib.open_signal();
1357        let id = tree.add(pib);
1358        tree.layout(SizeProposal::exact(300.0, 80.0));
1359        let trigger_id = tree
1360            .first_focusable_descendant(id)
1361            .expect("must expose a focusable IconButton");
1362        let b = tree.bounds(trigger_id);
1363        let caret_quadrant = Point::new(b.x + b.width * 0.85, b.y + b.height * 0.85);
1364        tree.pointer_down_button(caret_quadrant, PointerButton::Primary);
1365        tree.pointer_up_button(caret_quadrant, PointerButton::Primary);
1366        assert!(
1367            open_signal.get(),
1368            "clicking the caret quadrant of the IconButton must pass through",
1369        );
1370    }
1371
1372    #[test]
1373    fn icon_compact_skips_caret_but_still_builds() {
1374        let mut tree = light_tree();
1375        let id = tree.add(
1376            PopoverIconButton::new(IconButton::add().size(IconButtonSize::Compact))
1377                .content(dummy_content()),
1378        );
1379        tree.layout(SizeProposal::exact(300.0, 80.0));
1380        let _ = tree
1381            .first_focusable_descendant(id)
1382            .expect("focusable IconButton must be present at Compact");
1383    }
1384
1385    #[test]
1386    fn tooltip_appears_on_hover() {
1387        let mut tree = light_tree();
1388        let id = tree.add(
1389            PopoverButton::new(Button::new(lit!("Open")))
1390                .content(dummy_content())
1391                .tooltip(lit!("Tip")),
1392        );
1393        tree.layout(SizeProposal::exact(300.0, 80.0));
1394        tree.pointer_move(tree.bounds(id).center());
1395        tree.advance_time(std::time::Duration::from_secs(1));
1396        assert_eq!(
1397            tree.active_overlays().len(),
1398            1,
1399            "tooltip should appear on hover"
1400        );
1401        assert!(tree.find_by_label("Tip").is_some());
1402    }
1403
1404    #[derive(Debug)]
1405    struct FocusableLeaf;
1406    impl Widget for FocusableLeaf {
1407        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1408            ctx.apply_self_handlers(
1409                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1410            );
1411            vec![]
1412        }
1413        fn layout_response(
1414            &self,
1415            proposal: SizeProposal,
1416            _ctx: &LayoutContext,
1417        ) -> teksilo_core::widget::LayoutResponse {
1418            proposal.resolve(12.0, 12.0).into()
1419        }
1420    }
1421
1422    /// Open a popover, Tab past its last control, and it must go.
1423    ///
1424    /// A popover implements the Disclosure pattern, which mandates no focus
1425    /// containment — so Tab genuinely leaves. What must *not* survive that is
1426    /// the panel itself: an open popover with the focus ring somewhere behind
1427    /// it fails WCAG 2.2 SC 2.4.11 (Focus Not Obscured). Note the content's
1428    /// natural Tab slot is already correct — it is built as a child of the
1429    /// trigger, so it follows the trigger the way a disclosure's panel follows
1430    /// its button. Only the dismissal was missing.
1431    #[test]
1432    fn tab_out_of_popover_dismisses_it() {
1433        let mut tree = light_tree();
1434        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1435            crate::primitives::VStack::new()
1436                .child(FocusableLeaf)
1437                .child(FocusableLeaf),
1438        );
1439        let open_signal = pb.open_signal();
1440        let id = tree.add(pb);
1441        let after = tree.add(FocusableLeaf);
1442        tree.layout(SizeProposal::exact(300.0, 400.0));
1443        let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1444
1445        tree.focus(button_id);
1446        tree.dispatch_event(WidgetEvent::KeyDown {
1447            key: Key::Enter,
1448            modifiers: Modifiers::NONE,
1449            text: None,
1450        });
1451        tree.dispatch_event(WidgetEvent::KeyUp {
1452            key: Key::Enter,
1453            modifiers: Modifiers::NONE,
1454        });
1455        assert!(open_signal.get(), "precondition: Enter opens the popover");
1456        assert_eq!(tree.active_overlays().len(), 1);
1457
1458        // Tab within the content — two focusables, so the first Tab stays inside
1459        // and must NOT dismiss anything.
1460        tree.press_key(Key::Tab, Modifiers::NONE);
1461        assert_eq!(
1462            tree.active_overlays().len(),
1463            1,
1464            "moving between the popover's own controls is not leaving it"
1465        );
1466
1467        // The next Tab leaves the content for good.
1468        tree.press_key(Key::Tab, Modifiers::NONE);
1469        assert_eq!(tree.focused(), Some(after), "focus lands past the trigger");
1470        assert!(
1471            tree.active_overlays().is_empty(),
1472            "the popover must not stay open behind the focus ring"
1473        );
1474        assert!(!open_signal.get(), "and its open signal must follow");
1475    }
1476
1477    /// Shift+Tab off the front of the content leaves it just as surely — and
1478    /// lands on the trigger, which is where Escape would have left it.
1479    #[test]
1480    fn shift_tab_off_the_front_of_a_popover_dismisses_it() {
1481        let mut tree = light_tree();
1482        let pb = PopoverButton::new(Button::new(lit!("Open"))).content(
1483            crate::primitives::VStack::new()
1484                .child(FocusableLeaf)
1485                .child(FocusableLeaf),
1486        );
1487        let open_signal = pb.open_signal();
1488        let id = tree.add(pb);
1489        tree.add(FocusableLeaf);
1490        tree.layout(SizeProposal::exact(300.0, 400.0));
1491        let button_id = tree.first_focusable_descendant(id).expect("inner Button");
1492
1493        tree.focus(button_id);
1494        tree.dispatch_event(WidgetEvent::KeyDown {
1495            key: Key::Enter,
1496            modifiers: Modifiers::NONE,
1497            text: None,
1498        });
1499        tree.dispatch_event(WidgetEvent::KeyUp {
1500            key: Key::Enter,
1501            modifiers: Modifiers::NONE,
1502        });
1503        assert!(open_signal.get());
1504
1505        tree.press_key(Key::Tab, Modifiers::SHIFT);
1506        assert_eq!(tree.focused(), Some(button_id), "back onto the trigger");
1507        assert!(
1508            tree.active_overlays().is_empty(),
1509            "leaving through the front dismisses it too"
1510        );
1511    }
1512}