Skip to main content

teksilo_widgets/
overlay_trigger.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `OverlayTrigger` — the shared "this widget opens that overlay" wrapper.
5//!
6//! Used by `Dialog`, `Snackbar` and every other presenter that lets a caller
7//! replace its default `Button` trigger with a widget of their own.
8//!
9//! ## Touch and pen
10//!
11//! The trigger has no geometry and no press visual of its own: it forwards the
12//! caller's child, whose target and appearance are the child's, and routes the
13//! opening handlers onto that child's external bucket so they fire beside the
14//! child's own. The activation is an `on_tap`, so it happens on the release for
15//! every pointer kind.
16
17use teksilo_canvas::{Rect, SizeProposal};
18use teksilo_core::accessibility::AccessNodeBuilder;
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::signal::{Prop, Signal};
21use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
22use teksilo_core::widget_builder::HandlerSet;
23use teksilo_core::widget_id::WidgetId;
24
25/// Wraps an arbitrary widget so it can drive a popover.
26///
27/// `PopoverButton` and `PopoverIconButton` cover the two stock triggers; this
28/// is the third case — a trigger that is *not* a button, such as a table
29/// header's filter glyph or a tag chip. It supplies what those two get from
30/// `Button`/`IconButton`: an activate route (pointer, Enter/Space, and the
31/// AT `Click` action), the `has_popup` / `expanded` disclosure annotations, and
32/// the arena-level `enabled` gate.
33///
34/// ```ignore
35/// PopoverWidget::new(OverlayTrigger::around(my_glyph))
36///     .content(my_panel)
37///     .placement(OverlayPlacement::BelowPreferred)
38/// ```
39pub struct OverlayTrigger {
40    child_id: Option<WidgetId>,
41    pending_child: Option<PendingChild>,
42    pending_handlers: Option<HandlerSet>,
43    name: Option<String>,
44    /// Optional `has_popup` hint surfaced on this trigger's a11y
45    /// node. Same role as Button's equivalent — used by Popover
46    /// for the ARIA disclosure pattern.
47    has_popup: Option<teksilo_core::accesskit::HasPopup>,
48    /// Optional signal reporting whether the owned popup is
49    /// currently visible. Published via `set_expanded`.
50    expanded_signal: Option<Signal<bool>>,
51    /// Enabled state, wired into the arena on this trigger's node so
52    /// a disabled custom trigger greys out (via `effective_enabled`),
53    /// reports `disabled` to AT, and has its pointer/key dispatch
54    /// gated — the same treatment a stock `Button` gets. Default
55    /// `Prop::Static(true)`.
56    enabled: Prop<bool>,
57    /// Installed by [`crate::popover_widget::PopoverTrigger::with_on_activate`]. Routed onto the child
58    /// in `build` as pointer-tap and Enter/Space, and onto *this* node as the
59    /// AT `Click` action, so a custom trigger is reachable exactly the ways a
60    /// `Button` trigger is.
61    on_activate: Option<std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
62    /// The AT `Click` route for a presenter that builds its own
63    /// [`HandlerSet`] (`Dialog`, `Snackbar`) rather than going through
64    /// [`on_activate`](Self::on_activate).
65    ///
66    /// It has to be a *separate* setter, and it has to land on this node
67    /// rather than on the child, because this is the node that carries
68    /// `Role::Button`: an AT action dispatches to the node it was invoked on
69    /// and then bubbles towards the root, so a handler parked on the child —
70    /// a descendant — is never on its path. A presenter that put its AT
71    /// handler in the child's `HandlerSet` therefore published a named,
72    /// correctly-roled button that no screen reader could activate.
73    on_access_activate: Option<std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
74}
75
76impl OverlayTrigger {
77    pub(crate) fn new(child: Box<dyn Widget>, handlers: HandlerSet) -> Self {
78        Self::from_pending(PendingChild::Deferred(child), handlers)
79    }
80
81    pub(crate) fn from_id(id: WidgetId, handlers: HandlerSet) -> Self {
82        Self::from_pending(PendingChild::Id(id), handlers)
83    }
84
85    fn from_pending(pending: PendingChild, handlers: HandlerSet) -> Self {
86        Self {
87            child_id: None,
88            pending_child: Some(pending),
89            pending_handlers: Some(handlers),
90            name: None,
91            has_popup: None,
92            expanded_signal: None,
93            enabled: Prop::Static(true),
94            on_activate: None,
95            on_access_activate: None,
96        }
97    }
98
99    /// Wrap any widget as a popover trigger.
100    pub fn around(widget: impl Widget + 'static) -> Self {
101        Self::from_pending(
102            teksilo_core::IntoTeksiChild::into_pending(widget),
103            HandlerSet::new(),
104        )
105    }
106
107    /// [`around`](Self::around) for a widget already inserted by id.
108    pub fn around_id(id: WidgetId) -> Self {
109        Self::from_pending(PendingChild::Id(id), HandlerSet::new())
110    }
111
112    /// Set the trigger's accessible name.
113    pub fn named(self, name: impl Into<String>) -> Self {
114        self.name(name)
115    }
116
117    /// Whether an activate handler is already installed.
118    pub fn has_on_activate(&self) -> bool {
119        self.on_activate.is_some()
120    }
121
122    /// Install the popover's open/close handler. Routed onto the wrapped widget
123    /// as pointer-tap and Enter/Space, and onto this trigger's own node as the
124    /// AT `Click` action.
125    pub fn on_activate(
126        mut self,
127        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
128    ) -> Self {
129        self.on_activate = Some(std::rc::Rc::new(f));
130        self
131    }
132
133    /// Install *only* the AT `Click` route, on this trigger's own node.
134    ///
135    /// For a presenter that hands the pointer and keyboard routes over in its
136    /// own [`HandlerSet`] (which is applied to the child, so the child's
137    /// gesture arena cannot swallow them first) but still needs the AT action
138    /// on the node that carries `Role::Button`. See
139    /// [`on_access_activate`](Self::on_access_activate)'s field docs for why
140    /// the two cannot share a destination.
141    pub(crate) fn on_access_activate(
142        mut self,
143        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
144    ) -> Self {
145        self.on_access_activate = Some(std::rc::Rc::new(f));
146        self
147    }
148
149    /// Set the trigger's enabled state (static or reactive). When
150    /// `false`, the trigger child greys out, reports `disabled` to
151    /// AT, and stops accepting pointer/key dispatch — via the arena's
152    /// `enabled_when` cascade onto this node.
153    pub(crate) fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
154        self.enabled = enabled.into();
155        self
156    }
157
158    pub(crate) fn name(mut self, name: impl Into<String>) -> Self {
159        self.name = Some(name.into());
160        self
161    }
162
163    pub(crate) fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
164        self.has_popup = Some(kind);
165        self
166    }
167
168    pub(crate) fn expanded_when(mut self, signal: Signal<bool>) -> Self {
169        self.expanded_signal = Some(signal);
170        self
171    }
172}
173
174impl std::fmt::Debug for OverlayTrigger {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("OverlayTrigger")
177            .field("name", &self.name)
178            .finish()
179    }
180}
181
182impl Widget for OverlayTrigger {
183    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
184        // Wire enabled into the arena on this trigger node. The child is
185        // a descendant, so `arena.is_enabled` (ancestor walk) gates its
186        // dispatch, `effective_enabled` greys it out, and the a11y walker
187        // marks it disabled — with no per-trigger bool snapshot.
188        let self_id = ctx.self_id();
189        ctx.enabled_when(self_id, self.enabled.clone());
190        if let Some(pending) = self.pending_child.take() {
191            self.child_id = Some(match pending {
192                PendingChild::Id(id) => id,
193                PendingChild::Deferred(w) => ctx.add_boxed(w),
194            });
195        }
196        // Attach handlers to the CHILD, not to ourselves. The child is
197        // the hit-test target and the first node in the bubble pass —
198        // if it has its own gesture arena (e.g. a real `Button`, which
199        // unconditionally wires `on_tap` for InteractionState
200        // tracking), it consumes the tap before any ancestor can see
201        // it. Routing the overlay-opening handlers onto the child's
202        // *external* bucket means they fire alongside the child's own
203        // handlers when the gesture arena emits `Tap`.
204        //
205        // For non-interactive triggers (test `FixedLeaf`, `Panel`,
206        // etc.) `ensure_gesture_arena` lazily installs a recognizer
207        // for the external `on_tap`, so the same path works.
208        let mut handlers = self.pending_handlers.take();
209        if let Some(activate) = self.on_activate.clone() {
210            let set = handlers.take().unwrap_or_default();
211            let tap = activate.clone();
212            let key = activate.clone();
213            handlers = Some(
214                set.on_tap(move |_pos, ctx| tap(ctx))
215                    .on_key(move |event, ctx| match event {
216                        teksilo_core::event::WidgetEvent::KeyDown {
217                            key: teksilo_core::event::Key::Enter | teksilo_core::event::Key::Space,
218                            ..
219                        } => {
220                            key(ctx);
221                            teksilo_core::event::EventResponse::Handled
222                        }
223                        _ => teksilo_core::event::EventResponse::Ignored,
224                    }),
225            );
226            // The AT route splits off here and lands on SELF: an
227            // `AccessAction` dispatches to the node it was invoked on — this
228            // one, the node `accessibility` gives `Role::Button` — and then
229            // bubbles rootwards, so the child never sees it. There is no
230            // gesture arena to lose it to either, which is the whole reason
231            // tap and key go the other way.
232            if self.on_access_activate.is_none() {
233                self.on_access_activate = Some(activate);
234            }
235        }
236        if let Some(handlers) = handlers {
237            if let Some(child_id) = self.child_id {
238                ctx.apply_handlers(child_id, handlers);
239            } else {
240                // No child — keep handlers on self so they aren't lost.
241                ctx.apply_self_handlers(handlers);
242            }
243        }
244        if let Some(activate) = self.on_access_activate.clone() {
245            ctx.apply_self_handlers(HandlerSet::new().on_access_action(move |action, ctx| {
246                if action == teksilo_core::accesskit::Action::Click {
247                    activate(ctx);
248                    teksilo_core::event::EventResponse::Handled
249                } else {
250                    teksilo_core::event::EventResponse::Ignored
251                }
252            }));
253        }
254        // Register the expanded_signal so flips trigger an a11y
255        // refresh on this trigger node.
256        if let Some(ref expanded_signal) = self.expanded_signal {
257            let registry = ctx.binding_registry();
258            expanded_signal.bind_to(
259                self_id,
260                registry,
261                teksilo_core::binding::BindingLevel::RepaintOnly,
262            );
263        }
264        self.children()
265    }
266
267    fn layout_response(
268        &self,
269        proposal: SizeProposal,
270        ctx: &LayoutContext,
271    ) -> teksilo_core::widget::LayoutResponse {
272        self.child_id
273            .and_then(|id| ctx.child_size(id, proposal))
274            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
275            .into()
276    }
277
278    fn place_children(
279        &self,
280        bounds: Rect,
281        _proposal: SizeProposal,
282        children: &mut [WidgetPlacement],
283        _ctx: &LayoutContext,
284    ) {
285        for child in children.iter_mut() {
286            child.origin = bounds.origin();
287            child.size = bounds.size();
288        }
289    }
290
291    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
292        builder.set_role(teksilo_core::accesskit::Role::Button);
293        if let Some(name) = &self.name {
294            builder.set_name(name.as_str());
295        }
296        if let Some(kind) = self.has_popup {
297            builder.set_has_popup(kind);
298        }
299        if let Some(ref signal) = self.expanded_signal {
300            builder.set_expanded(signal.get());
301        }
302        // Advertise what this node can actually do. The handler alone is not
303        // enough: AccessKit consumers read the action list, `accesskit`'s own
304        // platform adapters refuse an unadvertised action, and an audit that
305        // only checks names and roles passes a button no screen reader can
306        // press. Only claimed when there is a route to claim — a bare
307        // `OverlayTrigger::around(w)` that no presenter has wired up yet
308        // advertises nothing, which is the truth about it.
309        if self.on_access_activate.is_some() {
310            builder.add_action(teksilo_core::accesskit::Action::Click);
311        }
312    }
313
314    fn children(&self) -> Vec<WidgetId> {
315        self.child_id.into_iter().collect()
316    }
317}