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`, `Wizard` and `PopoverWidget` whenever a
7//! caller replaces the default `Button` trigger with a widget of their own.
8//!
9//! ## One control, on whichever node takes focus
10//!
11//! To a screen reader a trigger is one control, and the node focus lands on is
12//! that control: it carries the role, the name, the popup state, and it
13//! answers Enter, Space and the AT `Click`. Which node that is depends on what
14//! was wrapped, and is decided once, as it mounts:
15//!
16//! * **A widget that takes no focus** (a panel, a glyph, a label): the
17//! trigger's own node is the button. It is the Tab stop and it carries
18//! everything above.
19//! * **A control of its own** (a `Button`, an `IconButton`, anything that is or
20//! holds a focus stop, even one disabled as it mounts): that control is the
21//! button. Its role and its own text stand, the opening routes and the popup
22//! state are added to it, and the trigger's node is structure, which the
23//! tree walk leaves out. The name given to the trigger is not used there:
24//! the control's own text is what a sighted user reads on it, and a second
25//! button around the first would be one control heard as two.
26//!
27//! ## Pointer, touch and pen
28//!
29//! The trigger has no geometry and no press visual of its own: it forwards the
30//! caller's child, whose target and appearance are the child's. The opener is
31//! an `on_tap`, attached beside the handlers already there (the external
32//! bucket), so it happens on the release for every pointer kind and never
33//! replaces what the child does itself. It goes on two nodes:
34//!
35//! * **the wrapped widget**, so a tap anywhere on it opens, its margins and
36//! its non-interactive parts included;
37//! * **the wrapped control**, when that is a node inside the widget rather
38//! than the widget itself. A control that handles taps of its own takes a
39//! tap before any ancestor sees it, so an opener left on the wrapper alone
40//! would answer Enter, Space and a reader's `Click` and never a click.
41//!
42//! A tap is delivered to the innermost node that recognizes it, and nowhere
43//! else, so a click on the control opens once, beside the control's own
44//! response to it.
45//!
46//! ## Opening on another button
47//!
48//! The trigger opens on the buttons the wrapped widget accepts taps from,
49//! [`ButtonMask::PRIMARY`](teksilo_core::event::ButtonMask::PRIMARY) unless
50//! it says otherwise. To open on a right click, give the wrapped widget
51//! `.accept_tap_buttons(ButtonMask::SECONDARY)`; the inner control gets the
52//! same mask for the opener, so a left click on it stays the control's own.
53//! A second click with the same button on an open trigger closes it.
54//!
55//! ```ignore
56//! PopoverWidget::new(OverlayTrigger::around(
57//! tag_chip.accept_tap_buttons(ButtonMask::SECONDARY),
58//! ))
59//! .content(tag_menu)
60//! ```
61
62use std::rc::Rc;
63
64use teksilo_canvas::{Rect, SizeProposal};
65use teksilo_core::accessibility::AccessNodeBuilder;
66use teksilo_core::accesskit::{Action, HasPopup, Role};
67use teksilo_core::build_context::BuildContext;
68use teksilo_core::event::{ButtonMask, EventResponse, Key, WidgetEvent};
69use teksilo_core::signal::{Prop, Signal};
70use teksilo_core::widget::{
71 CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
72};
73use teksilo_core::widget_builder::HandlerSet;
74use teksilo_core::widget_id::WidgetId;
75
76type Activate = Rc<dyn Fn(&mut EventContext)>;
77
78/// Wraps an arbitrary widget so it can drive a popover.
79///
80/// `PopoverButton` and `PopoverIconButton` cover the two stock triggers; this
81/// is the third case — a trigger that is *not* a button, such as a table
82/// header's filter glyph or a tag chip. It supplies what those two get from
83/// `Button`/`IconButton`: a Tab stop, an activate route (pointer, Enter/Space,
84/// and the AT `Click` action), the `has_popup` / `expanded` disclosure
85/// annotations, and the arena-level `enabled` gate. A wrapped widget that is a
86/// control already keeps its own focus, role and name; see the module docs.
87///
88/// ```ignore
89/// PopoverWidget::new(OverlayTrigger::around(my_glyph))
90/// .content(my_panel)
91/// .placement(OverlayPlacement::BelowPreferred)
92/// ```
93pub struct OverlayTrigger {
94 child_id: Option<WidgetId>,
95 pending_child: Option<PendingChild>,
96 name: Option<String>,
97 /// Optional `has_popup` hint surfaced on the trigger's control node.
98 /// Same role as Button's equivalent: used by Popover for the ARIA
99 /// disclosure pattern.
100 has_popup: Option<HasPopup>,
101 /// Optional signal reporting whether the owned popup is
102 /// currently visible. Published via `set_expanded`.
103 expanded_signal: Option<Signal<bool>>,
104 /// Enabled state, wired into the arena on this trigger's node so
105 /// a disabled custom trigger greys out (via `effective_enabled`),
106 /// reports `disabled` to AT, and has its pointer/key dispatch
107 /// gated — the same treatment a stock `Button` gets. Default
108 /// `Prop::Static(true)`.
109 enabled: Prop<bool>,
110 /// What opening the overlay does. Installed by the presenter (`Dialog`,
111 /// `Snackbar`, `Wizard`) or by
112 /// [`crate::popover_widget::PopoverTrigger::with_on_activate`], and routed
113 /// in `build` onto the child as a pointer tap and onto the control node as
114 /// Enter/Space and the AT `Click`, so a custom trigger is reachable exactly
115 /// the ways a `Button` trigger is.
116 on_activate: Option<Activate>,
117 /// Enter and Space open on the key's release rather than its press. The
118 /// modal presenters ask for it; a popover opens on the press.
119 activate_on_key_up: bool,
120 /// The wrapped widget's own focus stop, when it has one. Set as the child
121 /// mounts. `Some` makes that widget the control, and this node structure;
122 /// `None` makes this node the control. See the module docs.
123 wrapped_control: Option<WidgetId>,
124}
125
126impl OverlayTrigger {
127 pub(crate) fn new(child: Box<dyn Widget>) -> Self {
128 Self::from_pending(PendingChild::Deferred(child))
129 }
130
131 pub(crate) fn from_pending(pending: PendingChild) -> Self {
132 Self {
133 child_id: None,
134 pending_child: Some(pending),
135 name: None,
136 has_popup: None,
137 expanded_signal: None,
138 enabled: Prop::Static(true),
139 on_activate: None,
140 activate_on_key_up: false,
141 wrapped_control: None,
142 }
143 }
144
145 /// Wrap any widget as a popover trigger.
146 pub fn around(widget: impl Widget + 'static) -> Self {
147 Self::from_pending(teksilo_core::IntoTeksiChild::into_pending(widget))
148 }
149
150 /// [`around`](Self::around) for a widget already inserted by id.
151 pub fn around_id(id: WidgetId) -> Self {
152 Self::from_pending(PendingChild::Id(id))
153 }
154
155 /// Set the trigger's accessible name.
156 ///
157 /// Used when the wrapped widget takes no focus of its own. A wrapped
158 /// control keeps its own name.
159 pub fn named(self, name: impl Into<String>) -> Self {
160 self.name(name)
161 }
162
163 /// Whether an activate handler is already installed.
164 pub fn has_on_activate(&self) -> bool {
165 self.on_activate.is_some()
166 }
167
168 /// Install the overlay's open/close handler. Routed onto the wrapped widget
169 /// as a pointer tap, and onto the node that takes focus as Enter/Space and
170 /// the AT `Click` action.
171 pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
172 self.on_activate = Some(Rc::new(f));
173 self
174 }
175
176 /// Open on the release of Enter or Space rather than on the press.
177 pub(crate) fn activate_on_key_up(mut self) -> Self {
178 self.activate_on_key_up = true;
179 self
180 }
181
182 /// Set the trigger's enabled state (static or reactive). When
183 /// `false`, the trigger child greys out, reports `disabled` to
184 /// AT, and stops accepting pointer/key dispatch — via the arena's
185 /// `enabled_when` cascade onto this node.
186 pub(crate) fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
187 self.enabled = enabled.into();
188 self
189 }
190
191 pub(crate) fn name(mut self, name: impl Into<String>) -> Self {
192 self.name = Some(name.into());
193 self
194 }
195
196 pub(crate) fn has_popup(mut self, kind: HasPopup) -> Self {
197 self.has_popup = Some(kind);
198 self
199 }
200
201 pub(crate) fn expanded_when(mut self, signal: Signal<bool>) -> Self {
202 self.expanded_signal = Some(signal);
203 self
204 }
205
206 /// Enter/Space and the AT `Click`, for the node that takes focus.
207 fn keyboard_and_at(&self, activate: Activate) -> HandlerSet {
208 let on_key_up = self.activate_on_key_up;
209 let key = activate.clone();
210 HandlerSet::new()
211 .on_key(move |event, ctx| match event {
212 WidgetEvent::KeyDown {
213 key: Key::Enter | Key::Space,
214 ..
215 } if !on_key_up => {
216 key(ctx);
217 EventResponse::Handled
218 }
219 WidgetEvent::KeyUp {
220 key: Key::Enter | Key::Space,
221 ..
222 } if on_key_up => {
223 key(ctx);
224 EventResponse::Handled
225 }
226 _ => EventResponse::Ignored,
227 })
228 .on_access_action(move |action, ctx| {
229 if action == Action::Click {
230 activate(ctx);
231 EventResponse::Handled
232 } else {
233 EventResponse::Ignored
234 }
235 })
236 }
237
238 /// The popup state, written onto a wrapped control's own node after its
239 /// widget has described itself.
240 fn disclosure(&self) -> Option<impl Fn(&mut AccessNodeBuilder) + 'static> {
241 if self.has_popup.is_none() && self.expanded_signal.is_none() {
242 return None;
243 }
244 let has_popup = self.has_popup;
245 let expanded = self.expanded_signal.clone();
246 Some(move |builder: &mut AccessNodeBuilder| {
247 if let Some(kind) = has_popup {
248 builder.set_has_popup(kind);
249 }
250 if let Some(ref signal) = expanded {
251 builder.set_expanded(signal.get());
252 }
253 })
254 }
255}
256
257impl std::fmt::Debug for OverlayTrigger {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 f.debug_struct("OverlayTrigger")
260 .field("name", &self.name)
261 .finish()
262 }
263}
264
265impl Widget for OverlayTrigger {
266 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
267 // Wire enabled into the arena on this trigger node. The child is
268 // a descendant, so `arena.is_enabled` (ancestor walk) gates its
269 // dispatch, `effective_enabled` greys it out, and the a11y walker
270 // marks it disabled — with no per-trigger bool snapshot.
271 let self_id = ctx.self_id();
272 ctx.enabled_when(self_id, self.enabled.clone());
273 if let Some(pending) = self.pending_child.take() {
274 let child = match pending {
275 PendingChild::Id(id) => id,
276 PendingChild::Deferred(w) => ctx.add_boxed(w),
277 };
278 self.child_id = Some(child);
279 // `add` builds the child's whole subtree before it returns, so
280 // whether it is a control of its own is already known. A control
281 // disabled right now is still the control: asking only for what
282 // can take focus now would add a second, enabled stop beside it,
283 // one that opens what the disabled control is there to withhold.
284 self.wrapped_control = ctx.first_focus_capable_descendant(child);
285 }
286 if let Some(activate) = self.on_activate.clone() {
287 // The pointer route goes onto the CHILD, not onto ourselves. The
288 // child is the hit-test target and the first node in the bubble
289 // pass. If it has its own gesture arena (a real `Button`, which
290 // unconditionally wires `on_tap` for InteractionState tracking),
291 // it consumes the tap before any ancestor can see it. In the
292 // child's *external* bucket the opener fires alongside the child's
293 // own handlers when the gesture arena emits `Tap`. For a
294 // non-interactive child (a `Panel`, a glyph) `ensure_gesture_arena`
295 // lazily installs a recognizer for it. The same holds one level
296 // down for a control inside the child, which is why the opener
297 // goes on that control too (below).
298 let pointer = || {
299 let tap = activate.clone();
300 HandlerSet::new()
301 .on_tap(move |_event, ctx| tap(ctx))
302 .cursor(CursorIcon::Pointer)
303 };
304 // Keys and the AT action go to the node that takes focus: key
305 // events are dispatched there, and an `AccessAction` to the node a
306 // reader is on, bubbling only if that node leaves it unhandled,
307 // which a `Button` never does.
308 let keys = self.keyboard_and_at(activate.clone());
309 match (self.child_id, self.wrapped_control) {
310 (Some(child), Some(control)) => {
311 ctx.apply_handlers(child, pointer());
312 if control != child {
313 // A control inside the wrapped widget that handles
314 // taps itself takes every tap on it before the
315 // wrapper's recognizer can: the opener goes beside
316 // the control's own tap as well, with the buttons the
317 // caller chose on the wrapper. A tap is recognized by
318 // the innermost node only, so it still opens once.
319 // Left unset for the default, so a mask the
320 // composite itself declared on that bucket stands.
321 let mut on_control = pointer();
322 if let Some(mask) = ctx.attached_tap_buttons(child)
323 && mask != ButtonMask::PRIMARY
324 {
325 on_control = on_control.accept_tap_buttons(mask);
326 }
327 ctx.apply_handlers(control, on_control);
328 }
329 let keys = match self.disclosure() {
330 Some(disclosure) => keys.access_customize(disclosure),
331 None => keys,
332 };
333 ctx.apply_handlers(control, keys);
334 }
335 (Some(child), None) => {
336 ctx.apply_handlers(child, pointer());
337 ctx.apply_self_handlers(keys.focusable(true));
338 }
339 (None, _) => {
340 ctx.apply_self_handlers(pointer());
341 ctx.apply_self_handlers(keys.focusable(true));
342 }
343 }
344 }
345 // Register the expanded_signal so flips trigger an a11y
346 // refresh on this trigger node.
347 if let Some(ref expanded_signal) = self.expanded_signal {
348 let registry = ctx.binding_registry();
349 expanded_signal.bind_to(
350 self_id,
351 registry,
352 teksilo_core::binding::BindingLevel::RepaintOnly,
353 );
354 }
355 self.children()
356 }
357
358 fn layout_response(
359 &self,
360 proposal: SizeProposal,
361 ctx: &LayoutContext,
362 ) -> teksilo_core::widget::LayoutResponse {
363 self.child_id
364 .and_then(|id| ctx.child_size(id, proposal))
365 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
366 .into()
367 }
368
369 fn place_children(
370 &self,
371 bounds: Rect,
372 _proposal: SizeProposal,
373 children: &mut [WidgetPlacement],
374 _ctx: &LayoutContext,
375 ) {
376 for child in children.iter_mut() {
377 child.origin = bounds.origin();
378 child.size = bounds.size();
379 }
380 }
381
382 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
383 if self.wrapped_control.is_some() {
384 // The wrapped control is the button; this node is structure,
385 // dropped by the walker and by every adapter with its child kept.
386 builder.set_role(Role::GenericContainer);
387 return;
388 }
389 builder.set_role(Role::Button);
390 if let Some(name) = &self.name {
391 builder.set_name(name.as_str());
392 }
393 if let Some(kind) = self.has_popup {
394 builder.set_has_popup(kind);
395 }
396 if let Some(ref signal) = self.expanded_signal {
397 builder.set_expanded(signal.get());
398 }
399 // Advertise what this node can actually do. The handler alone is not
400 // enough: AccessKit consumers read the action list, `accesskit`'s own
401 // platform adapters refuse an unadvertised action, and an audit that
402 // only checks names and roles passes a button no screen reader can
403 // press. Only claimed when there is a route to claim — a bare
404 // `OverlayTrigger::around(w)` that no presenter has wired up yet
405 // advertises nothing, which is the truth about it.
406 if self.on_activate.is_some() {
407 builder.add_action(Action::Click);
408 }
409 }
410
411 fn children(&self) -> Vec<WidgetId> {
412 self.child_id.into_iter().collect()
413 }
414
415 /// A wrapped control stands for the trigger, so what is attached to the
416 /// trigger reaches the node a reader is on: a popover's dialog, named by
417 /// its trigger through `labelled_by`, is named by the control's text.
418 fn accessibility_proxy(&self) -> Option<WidgetId> {
419 self.wrapped_control
420 }
421}
422
423#[cfg(test)]
424mod pointer_tests;
425#[cfg(test)]
426mod reader_tests;