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}