Skip to main content

teksilo_widgets/
overlay_trigger.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::{Rect, SizeProposal};
5use teksilo_core::accessibility::AccessNodeBuilder;
6use teksilo_core::build_context::BuildContext;
7use teksilo_core::signal::{Prop, Signal};
8use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
9use teksilo_core::widget_builder::HandlerSet;
10use teksilo_core::widget_id::WidgetId;
11
12/// Wraps an arbitrary widget so it can drive a popover.
13///
14/// `PopoverButton` and `PopoverIconButton` cover the two stock triggers; this
15/// is the third case — a trigger that is *not* a button, such as a table
16/// header's filter glyph or a tag chip. It supplies what those two get from
17/// `Button`/`IconButton`: an activate route (pointer, Enter/Space, and the
18/// AT `Click` action), the `has_popup` / `expanded` disclosure annotations, and
19/// the arena-level `enabled` gate.
20///
21/// ```ignore
22/// PopoverWidget::new(OverlayTrigger::around(my_glyph))
23///     .content(my_panel)
24///     .placement(OverlayPlacement::BelowPreferred)
25/// ```
26pub struct OverlayTrigger {
27    child_id: Option<WidgetId>,
28    pending_child: Option<PendingChild>,
29    pending_handlers: Option<HandlerSet>,
30    name: Option<String>,
31    /// Optional `has_popup` hint surfaced on this trigger's a11y
32    /// node. Same role as Button's equivalent — used by Popover
33    /// for the ARIA disclosure pattern.
34    has_popup: Option<teksilo_core::accesskit::HasPopup>,
35    /// Optional signal reporting whether the owned popup is
36    /// currently visible. Published via `set_expanded`.
37    expanded_signal: Option<Signal<bool>>,
38    /// Enabled state, wired into the arena on this trigger's node so
39    /// a disabled custom trigger greys out (via `effective_enabled`),
40    /// reports `disabled` to AT, and has its pointer/key dispatch
41    /// gated — the same treatment a stock `Button` gets. Default
42    /// `Prop::Static(true)`.
43    enabled: Prop<bool>,
44    /// Installed by [`crate::popover_widget::PopoverTrigger::with_on_activate`]. Routed onto the child
45    /// in `build` as pointer-tap, Enter/Space and the AT `Click` action, so a
46    /// custom trigger is reachable exactly the ways a `Button` trigger is.
47    on_activate: Option<std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
48}
49
50impl OverlayTrigger {
51    pub(crate) fn new(child: Box<dyn Widget>, handlers: HandlerSet) -> Self {
52        Self::from_pending(PendingChild::Deferred(child), handlers)
53    }
54
55    pub(crate) fn from_id(id: WidgetId, handlers: HandlerSet) -> Self {
56        Self::from_pending(PendingChild::Id(id), handlers)
57    }
58
59    fn from_pending(pending: PendingChild, handlers: HandlerSet) -> Self {
60        Self {
61            child_id: None,
62            pending_child: Some(pending),
63            pending_handlers: Some(handlers),
64            name: None,
65            has_popup: None,
66            expanded_signal: None,
67            enabled: Prop::Static(true),
68            on_activate: None,
69        }
70    }
71
72    /// Wrap any widget as a popover trigger.
73    pub fn around(widget: impl Widget + 'static) -> Self {
74        Self::from_pending(PendingChild::Deferred(Box::new(widget)), HandlerSet::new())
75    }
76
77    /// [`around`](Self::around) for a widget already inserted by id.
78    pub fn around_id(id: WidgetId) -> Self {
79        Self::from_pending(PendingChild::Id(id), HandlerSet::new())
80    }
81
82    /// Set the trigger's accessible name.
83    pub fn named(self, name: impl Into<String>) -> Self {
84        self.name(name)
85    }
86
87    /// Whether an activate handler is already installed.
88    pub fn has_on_activate(&self) -> bool {
89        self.on_activate.is_some()
90    }
91
92    /// Install the popover's open/close handler. Routed onto the wrapped widget
93    /// as pointer-tap, Enter/Space and the AT `Click` action.
94    pub fn on_activate(
95        mut self,
96        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
97    ) -> Self {
98        self.on_activate = Some(std::rc::Rc::new(f));
99        self
100    }
101
102    /// Set the trigger's enabled state (static or reactive). When
103    /// `false`, the trigger child greys out, reports `disabled` to
104    /// AT, and stops accepting pointer/key dispatch — via the arena's
105    /// `enabled_when` cascade onto this node.
106    pub(crate) fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
107        self.enabled = enabled.into();
108        self
109    }
110
111    pub(crate) fn name(mut self, name: impl Into<String>) -> Self {
112        self.name = Some(name.into());
113        self
114    }
115
116    pub(crate) fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
117        self.has_popup = Some(kind);
118        self
119    }
120
121    pub(crate) fn expanded_when(mut self, signal: Signal<bool>) -> Self {
122        self.expanded_signal = Some(signal);
123        self
124    }
125}
126
127impl std::fmt::Debug for OverlayTrigger {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("OverlayTrigger")
130            .field("name", &self.name)
131            .finish()
132    }
133}
134
135impl Widget for OverlayTrigger {
136    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
137        // Wire enabled into the arena on this trigger node. The child is
138        // a descendant, so `arena.is_enabled` (ancestor walk) gates its
139        // dispatch, `effective_enabled` greys it out, and the a11y walker
140        // marks it disabled — with no per-trigger bool snapshot.
141        let self_id = ctx.self_id();
142        ctx.enabled_when(self_id, self.enabled.clone());
143        if let Some(pending) = self.pending_child.take() {
144            self.child_id = Some(match pending {
145                PendingChild::Id(id) => id,
146                PendingChild::Deferred(w) => ctx.add_boxed(w),
147            });
148        }
149        // Attach handlers to the CHILD, not to ourselves. The child is
150        // the hit-test target and the first node in the bubble pass —
151        // if it has its own gesture arena (e.g. a real `Button`, which
152        // unconditionally wires `on_tap` for InteractionState
153        // tracking), it consumes the tap before any ancestor can see
154        // it. Routing the overlay-opening handlers onto the child's
155        // *external* bucket means they fire alongside the child's own
156        // handlers when the gesture arena emits `Tap`.
157        //
158        // For non-interactive triggers (test `FixedLeaf`, `Panel`,
159        // etc.) `ensure_gesture_arena` lazily installs a recognizer
160        // for the external `on_tap`, so the same path works.
161        let mut handlers = self.pending_handlers.take();
162        if let Some(activate) = self.on_activate.clone() {
163            let set = handlers.take().unwrap_or_default();
164            let tap = activate.clone();
165            let key = activate.clone();
166            let act = activate;
167            handlers = Some(
168                set.on_tap(move |_pos, ctx| tap(ctx))
169                    .on_key(move |event, ctx| match event {
170                        teksilo_core::event::WidgetEvent::KeyDown {
171                            key: teksilo_core::event::Key::Enter | teksilo_core::event::Key::Space,
172                            ..
173                        } => {
174                            key(ctx);
175                            teksilo_core::event::EventResponse::Handled
176                        }
177                        _ => teksilo_core::event::EventResponse::Ignored,
178                    })
179                    .on_access_action(move |action, ctx| {
180                        if action == teksilo_core::accesskit::Action::Click {
181                            act(ctx);
182                            teksilo_core::event::EventResponse::Handled
183                        } else {
184                            teksilo_core::event::EventResponse::Ignored
185                        }
186                    }),
187            );
188        }
189        if let Some(handlers) = handlers {
190            if let Some(child_id) = self.child_id {
191                ctx.apply_handlers(child_id, handlers);
192            } else {
193                // No child — keep handlers on self so they aren't lost.
194                ctx.apply_self_handlers(handlers);
195            }
196        }
197        // Register the expanded_signal so flips trigger an a11y
198        // refresh on this trigger node.
199        if let Some(ref expanded_signal) = self.expanded_signal {
200            let registry = ctx.binding_registry();
201            expanded_signal.bind_to(
202                self_id,
203                registry,
204                teksilo_core::binding::BindingLevel::RepaintOnly,
205            );
206        }
207        self.children()
208    }
209
210    fn layout_response(
211        &self,
212        proposal: SizeProposal,
213        ctx: &LayoutContext,
214    ) -> teksilo_core::widget::LayoutResponse {
215        self.child_id
216            .and_then(|id| ctx.child_size(id, proposal))
217            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
218            .into()
219    }
220
221    fn place_children(
222        &self,
223        bounds: Rect,
224        _proposal: SizeProposal,
225        children: &mut [WidgetPlacement],
226        _ctx: &LayoutContext,
227    ) {
228        for child in children.iter_mut() {
229            child.origin = bounds.origin();
230            child.size = bounds.size();
231        }
232    }
233
234    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
235        builder.set_role(teksilo_core::accesskit::Role::Button);
236        if let Some(name) = &self.name {
237            builder.set_name(name.as_str());
238        }
239        if let Some(kind) = self.has_popup {
240            builder.set_has_popup(kind);
241        }
242        if let Some(ref signal) = self.expanded_signal {
243            builder.set_expanded(signal.get());
244        }
245    }
246
247    fn children(&self) -> Vec<WidgetId> {
248        self.child_id.into_iter().collect()
249    }
250}