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