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`, `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;