Skip to main content

teksilo_widgets/
search_field.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! SearchField — a [`TextInput`] preset
5//! configured for search workflows: leading magnifier glyph, default-on
6//! clear-X, and an optional anchored suggestions popover with keyboard
7//! navigation and the ARIA combobox-with-listbox accessibility pattern.
8//! The popover is shown via `OverlayRequest` so it floats above sibling
9//! content and escapes ancestor clipping (same pattern as `ComboBox`).
10//!
11//! ```ignore
12//! let query = ctx.signal(String::new());
13//! SearchField::new(query.clone())
14//!     .placeholder("Search documents")
15//!     .with_suggestions(|prefix| {
16//!         FRUITS.iter()
17//!             .filter(|f| f.to_lowercase().starts_with(&prefix.to_lowercase()))
18//!             .map(|s| s.to_string())
19//!             .collect()
20//!     })
21//!     .on_select(|value, _ctx| println!("picked: {value}"))
22//!     .on_submit_fn(|ctx| ctx.send_intent(AppIntent::Search))
23//! ```
24//!
25//! ## Design — comparison with searchable [`ComboBox`](crate::combo_box::ComboBox)
26//!
27//! A searchable `ComboBox` and a `SearchField` are visually similar
28//! but semantically different:
29//!
30//! - **ComboBox** is a *value picker* — the bound state is the
31//!   selected item from a known list. The text input is a transient
32//!   filter, embedded inside the dropdown popup; the closed combo
33//!   shows the selected value, not the user's query.
34//! - **SearchField** is a *query input* — the bound state is the
35//!   query string itself. The text input is always visible at the
36//!   top level; suggestions are completion hints, not the source of
37//!   truth. The bound `Signal<String>` keeps whatever the user
38//!   typed, even if no suggestion matches.
39//!
40//! The two share the same dropdown-of-options machinery in spirit;
41//! a future refactor could lift a common `OverlayList<T>` primitive
42//! out of both. For now they're separate so each can keep a small
43//! API surface tuned to its semantics.
44//!
45//! ## Accessibility
46//!
47//! The field is `Role::SearchInput` with `HasPopup::Listbox` and
48//! `AutoComplete::List`. When the popup is open it advertises
49//! `set_expanded(true)` and `set_controls(listbox_id)` (mapped to
50//! `accesskit::NodeId` via `widget_id_to_node_id`). Each row is
51//! `Role::ListBoxOption` with `set_selected(is_highlighted)` and
52//! `set_position_in_set(idx + 1)`; the `Role::ListBox` container
53//! carries the matching `set_size_of_set(total)`, since AccessKit
54//! resolves a set size by walking up from the item. Together they
55//! let screen readers announce "Apple, 1 of 5".
56
57use std::cell::{Cell, RefCell};
58use std::rc::Rc;
59use teksilo_i18n::lit;
60
61use teksilo_canvas::{Rect, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::event::{EventResponse, Key, WidgetEvent};
65use teksilo_core::overlay::{
66    DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
67};
68use teksilo_core::signal::{Prop, Signal};
69use teksilo_core::styles::{PopoverStyleConfig, PopoverVariant};
70use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
71use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
72use teksilo_core::widget_id::WidgetId;
73use teksilo_tokens::{CornerRadius, HAlignment, SurfaceRole, TextRole, TextStyleRole};
74
75use crate::icon_button::BuiltInIcons;
76use crate::primitives::{
77    Center, FixedSize, MinSize, Padding, RectWidget, TextWidget, VStack, ZStack,
78};
79use crate::text_input::TextInput;
80use teksilo_i18n::LocalizedString;
81
82/// Default cap on the number of suggestions rendered in the popup.
83/// Lives in code (not in `SearchFieldStyle`) because it's a behavior
84/// default, not a visual dimension — apps override per-instance via
85/// [`SearchField::max_suggestions`].
86const DEFAULT_MAX_SUGGESTIONS: usize = 8;
87
88type SuggestionProvider = Rc<dyn Fn(&str) -> Vec<String>>;
89type OnSelect = Rc<dyn Fn(&str, &mut EventContext)>;
90type OnSubmit = Rc<dyn Fn(&mut EventContext)>;
91
92fn search_glyph(glyph_size: f32, slot_width: f32) -> impl Widget + 'static {
93    let icon = (BuiltInIcons::global().search)()
94        .icon_size(glyph_size)
95        .color(TextRole::Secondary);
96    // `height` is load-bearing — without it, `Center`'s
97    // `proposal.resolve(0, 0)` collapses the unspecified-height side
98    // to zero and the slot disappears even though its width is set.
99    FixedSize::new()
100        .width(slot_width)
101        .height(glyph_size)
102        .child(Center::new().child(icon))
103}
104
105/// A search input with optional inline suggestions popup.
106pub struct SearchField {
107    text: Signal<String>,
108    placeholder: Option<LocalizedString>,
109    label: Option<LocalizedString>,
110    /// Initial enabled-state; forwarded to the arena at build time.
111    enabled: Prop<bool>,
112    suggestion_provider: Option<SuggestionProvider>,
113    max_suggestions: usize,
114    min_chars: usize,
115    on_select: Option<OnSelect>,
116    on_submit: Option<OnSubmit>,
117    /// ARIA combobox wiring for an **externally owned** listbox — a command
118    /// palette's result list, not this field's own suggestion popup — forwarded
119    /// to the inner `TextInput` and from there to the focusable
120    /// `TextInputField`. Independent of `row_ids_slot` / `highlighted_slot`,
121    /// which serve the built-in suggestion panel.
122    external_active_descendant: Option<Signal<Option<WidgetId>>>,
123    external_controls: Option<Signal<Option<WidgetId>>>,
124    /// Build state — populated in `build()`.
125    root_child_id: Option<WidgetId>,
126    /// Slot the SuggestionPanel writes its inner ListBox WidgetId into,
127    /// so SearchField's `accessibility()` can publish `set_controls`.
128    listbox_id_slot: Rc<Cell<Option<WidgetId>>>,
129    /// Slot the SuggestionPanel writes its current rows' WidgetIds
130    /// into (in display order). `accessibility()` uses it together
131    /// with `highlighted_slot` to publish `set_active_descendant` —
132    /// the ARIA pattern for an editable combobox so screen readers
133    /// announce arrow-key navigation through suggestions while focus
134    /// stays on the field. Rebuilt every time the suggestions list
135    /// changes, in lockstep with `listbox_id_slot`.
136    row_ids_slot: Rc<RefCell<Vec<WidgetId>>>,
137    /// Mirror of the `highlighted` signal, read by `accessibility()`
138    /// to look up the currently-active row id. Stored in a `RefCell`
139    /// because it's populated in `build()` and read from `&self`.
140    highlighted_slot: RefCell<Option<Signal<Option<usize>>>>,
141    /// Pre-created suggestions panel content. Inserted as a dormant
142    /// arena root in `build()` and shown as an overlay anchored to
143    /// the field via `OverlayRequest`. Added detached, so the
144    /// framework owns the teardown — the previous panel is reaped on
145    /// rebuild and the live one when the field is destroyed.
146    panel_content_id: Option<WidgetId>,
147    /// Whether the suggestions overlay is currently shown. Set true
148    /// when the open helper fires `ctx.show_overlay`, set false by the
149    /// dismiss callback registered on every `OverlayRequest`. Read by
150    /// `accessibility()` to drive `set_expanded`. Built in `build()`,
151    /// reused across rebuilds.
152    overlay_open: RefCell<Option<Signal<bool>>>,
153    /// Per-call style override.
154    style_override: Option<teksilo_core::styles::SharedSearchFieldStyle>,
155    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
156    /// with the rich / composite slots — every setter clears the other two so
157    /// the last call wins.
158    tooltip_text: Option<LocalizedString>,
159    /// Optional rich tooltip source (registry key or inline content).
160    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
161    /// Optional composite tooltip body (arbitrary widget tree).
162    composite_tooltip_content: Option<Box<dyn Widget>>,
163}
164
165impl SearchField {
166    /// Create a search field bound to `text`, the reactive query string.
167    pub fn new(text: Signal<String>) -> Self {
168        Self {
169            text,
170            placeholder: None,
171            label: None,
172            enabled: Prop::Static(true),
173            suggestion_provider: None,
174            max_suggestions: DEFAULT_MAX_SUGGESTIONS,
175            min_chars: 1,
176            on_select: None,
177            on_submit: None,
178            external_active_descendant: None,
179            external_controls: None,
180            root_child_id: None,
181            listbox_id_slot: Rc::new(Cell::new(None)),
182            row_ids_slot: Rc::new(RefCell::new(Vec::new())),
183            highlighted_slot: RefCell::new(None),
184            panel_content_id: None,
185            overlay_open: RefCell::new(None),
186            style_override: None,
187            tooltip_text: None,
188            rich_tooltip_source: None,
189            composite_tooltip_content: None,
190        }
191    }
192
193    /// Per-call SearchFieldStyle override.
194    pub fn style(mut self, style: impl teksilo_core::styles::SearchFieldStyle) -> Self {
195        self.style_override = Some(Rc::new(style));
196        self
197    }
198
199    /// Set the placeholder text shown when the query is empty.
200    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
201        let ls: LocalizedString = text.into();
202        self.placeholder = Some(ls);
203        self
204    }
205
206    /// Set an accessible label for the field (announced by screen readers, not visually shown).
207    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
208        let ls: LocalizedString = label.into();
209        self.label = Some(ls);
210        self
211    }
212
213    /// Wire this field to a listbox **the caller owns**, so arrow keys that
214    /// move a highlight through that list are announced while focus stays here
215    /// (the ARIA combobox pattern). `listbox` is the list's node, `active` the
216    /// currently-highlighted row's node; both are forwarded to the inner
217    /// `TextInputField`, which is the node that actually holds focus and
218    /// therefore the only one whose `active_descendant` assistive technology
219    /// follows.
220    ///
221    /// This is for a search field driving a list built by its *host* — a
222    /// command palette, a filter box above a results view. The built-in
223    /// suggestion popup (`suggestions`) wires itself and needs none of this.
224    pub fn drives_listbox(
225        mut self,
226        listbox: Signal<Option<WidgetId>>,
227        active: Signal<Option<WidgetId>>,
228    ) -> Self {
229        self.external_controls = Some(listbox);
230        self.external_active_descendant = Some(active);
231        self
232    }
233
234    /// Set the initial enabled state. Forwarded to the arena at build time.
235    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
236        self.enabled = on.into();
237        self
238    }
239
240    /// Install a callback invoked when the user presses Enter (or activates the search action).
241    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
242        self.on_submit = Some(Rc::new(f));
243        self
244    }
245
246    /// Provider that returns suggestions for the current query string.
247    /// When set, the popup appears below the field as soon as the
248    /// user types at least [`Self::min_chars`] characters and the
249    /// provider returns a non-empty list.
250    pub fn with_suggestions(mut self, f: impl Fn(&str) -> Vec<String> + 'static) -> Self {
251        self.suggestion_provider = Some(Rc::new(f));
252        self
253    }
254
255    /// Cap the number of suggestions shown in the popup (default 8, minimum 1).
256    pub fn max_suggestions(mut self, n: usize) -> Self {
257        self.max_suggestions = n.max(1);
258        self
259    }
260
261    /// Minimum number of characters the user must type before suggestions appear (default 1).
262    pub fn min_chars(mut self, n: usize) -> Self {
263        self.min_chars = n;
264        self
265    }
266
267    /// Install a callback invoked when the user picks a suggestion (tap, Enter, or Space).
268    pub fn on_select(mut self, f: impl Fn(&str, &mut EventContext) + 'static) -> Self {
269        self.on_select = Some(Rc::new(f));
270        self
271    }
272
273    /// Show a plain one-line tooltip after a hover delay.
274    ///
275    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
276    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
277    /// [`composite_tooltip`](Self::composite_tooltip) — calling this
278    /// clears the other slots (last call wins).
279    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
280        self.tooltip_text = Some(text.into());
281        self.rich_tooltip_source = None;
282        self.composite_tooltip_content = None;
283        self
284    }
285
286    /// Show a registry-driven rich tooltip keyed by `key`.
287    ///
288    /// Mutually exclusive with the other tooltip setters — last call wins.
289    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
290        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
291        self.tooltip_text = None;
292        self.composite_tooltip_content = None;
293        self
294    }
295
296    /// Show an inline rich tooltip with the given [`TooltipContent`](crate::tooltip::TooltipContent).
297    ///
298    /// Mutually exclusive with the other tooltip setters — last call wins.
299    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
300        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
301        self.tooltip_text = None;
302        self.composite_tooltip_content = None;
303        self
304    }
305
306    /// Show a composite tooltip whose body is an arbitrary widget tree.
307    ///
308    /// Mutually exclusive with the other tooltip setters — last call wins.
309    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
310        self.composite_tooltip_content = Some(Box::new(content));
311        self.tooltip_text = None;
312        self.rich_tooltip_source = None;
313        self
314    }
315}
316
317impl std::fmt::Debug for SearchField {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        f.debug_struct("SearchField")
320            .field("placeholder", &self.placeholder)
321            .field("max_suggestions", &self.max_suggestions)
322            .field("min_chars", &self.min_chars)
323            .finish_non_exhaustive()
324    }
325}
326
327impl Widget for SearchField {
328    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
329        let self_id = ctx.self_id();
330        // Forward initial-enabled into the arena; see IconButton.
331        ctx.enabled_when(self_id, self.enabled.clone());
332
333        // ── Reactive state ──────────────────────────────────────────
334        // Suggestions list — recomputed on every text change.
335        let suggestions: Signal<Vec<String>> = ctx.signal(Vec::new());
336        // Currently highlighted row inside the popup. Driven by
337        // ArrowUp / ArrowDown and by hover.
338        let highlighted: Signal<Option<usize>> = ctx.signal::<Option<usize>>(None);
339        // "User pressed Escape" / "User picked a suggestion" flag —
340        // suppresses the popup until the user starts typing again or
341        // refocuses the field. Reset on focus-gain and on any
342        // non-Escape KeyDown.
343        let dismissed: Signal<bool> = ctx.signal(false);
344
345        // ── TextInput with submit hook ──────────────────────────────
346        // Built inline (matching DateEdit / TimeEdit / SpinBox) — no
347        // helper method or stored Option<TextInput>, just direct
348        // construction from the SearchField's own config fields.
349        use crate::styles::recipe_search_field_style as sf;
350        let placeholder = self
351            .placeholder
352            .clone()
353            .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_builtin_search()));
354        let on_submit = self.on_submit.clone();
355        let on_select = self.on_select.clone();
356        let text_signal = self.text.clone();
357        let highlighted_for_submit = highlighted.clone();
358        let suggestions_for_submit = suggestions.clone();
359        let dismissed_for_submit = dismissed.clone();
360        let has_suggestions = self.suggestion_provider.is_some();
361
362        let mut input = TextInput::new(self.text.clone())
363            .placeholder(placeholder)
364            .show_clear_button(true)
365            .leading_slot(search_glyph(sf::GLYPH_SIZE, sf::GLYPH_SLOT_WIDTH))
366            .enabled(self.enabled.clone())
367            .on_submit_fn(move |ctx| {
368                let idx = highlighted_for_submit.get();
369                let list = suggestions_for_submit.get();
370                if let Some(i) = idx {
371                    if let Some(value) = list.get(i).cloned() {
372                        text_signal.set(value.clone());
373                        if let Some(handler) = &on_select {
374                            handler(&value, ctx);
375                        }
376                        dismissed_for_submit.set(true);
377                        // Close the popover after picking a
378                        // suggestion — without this, the panel
379                        // stays open showing just the picked item
380                        // until the user clicks outside.
381                        ctx.dismiss_all_except_hosts();
382                        return;
383                    }
384                }
385                if let Some(handler) = &on_submit {
386                    handler(ctx);
387                }
388                dismissed_for_submit.set(true);
389                // Closing overlays here exists solely to take down the
390                // *suggestion panel*. With no provider there is no panel, and
391                // this call is pure collateral damage: `dismiss_scope` is a
392                // single slot, so it silently overwrites whatever the caller's
393                // own `on_submit` handler just asked for. A `SearchField` inside
394                // a popover — a "go to" palette, a picker — could therefore
395                // never close its own popover on Enter, and the reason was
396                // invisible from the call site.
397                if has_suggestions {
398                    ctx.dismiss_all_except_hosts();
399                }
400            });
401        if let Some(label) = &self.label {
402            input = input.label(label.clone());
403        }
404        if let Some(active) = self.external_active_descendant.clone() {
405            input = input.active_descendant(active);
406        }
407        if let Some(listbox) = self.external_controls.clone() {
408            input = input.controls(listbox);
409        }
410        let input_id = ctx.add(input);
411
412        // ── Suggestions provider effect ─────────────────────────────
413        // Recomputes the list on every text change. The popup's
414        // visibility is driven by a separate derived signal further
415        // down, so this effect only mutates `suggestions` /
416        // `highlighted`.
417        if let Some(provider) = self.suggestion_provider.clone() {
418            let suggestions = suggestions.clone();
419            let highlighted = highlighted.clone();
420            let max = self.max_suggestions;
421            let min_chars = self.min_chars;
422            ctx.effect(&self.text, move |text| {
423                let len = text.chars().count();
424                if len < min_chars {
425                    suggestions.set(Vec::new());
426                    highlighted.set(None);
427                    return;
428                }
429                let mut matches = provider(text);
430                if matches.len() > max {
431                    matches.truncate(max);
432                }
433                suggestions.set(matches);
434                highlighted.set(None);
435            });
436        }
437
438        // ── Suggestions panel (overlay content, anchored to the field) ─
439        // Detached rather than a child (it must not wake or paint with the
440        // field), and `add_detached` rather than `ctx.add` so the framework
441        // owns it: the previous panel is reaped on rebuild, and the live one
442        // when the field itself is destroyed. This used to be a hand-rolled
443        // `destroy_subtree` of the old id here, which covered the rebuild but
444        // not the destroy — a `SearchField` that went away took nothing with
445        // it.
446        let panel = SuggestionPanel {
447            text: self.text.clone(),
448            suggestions: suggestions.clone(),
449            highlighted: highlighted.clone(),
450            on_select: self.on_select.clone(),
451            dismissed: dismissed.clone(),
452            listbox_id_slot: self.listbox_id_slot.clone(),
453            row_ids_slot: self.row_ids_slot.clone(),
454            root_child_id: None,
455        };
456        // ── Open / dismiss state ────────────────────────────────────
457        // `overlay_open` mirrors the live overlay state: set to true by
458        // the open helper before `ctx.show_overlay`, set to false by
459        // the dismiss callback the overlay manager invokes (Escape,
460        // outside click, programmatic dismiss). Read by
461        // `accessibility()` for `set_expanded`.
462        //
463        // Created before the panel because it is also the panel's reveal gate:
464        // the suggestion list is built the first time the field actually
465        // suggests something, not on every rebuild of the field.
466        let overlay_open = ctx.signal(false);
467        *self.overlay_open.borrow_mut() = Some(overlay_open.clone());
468
469        let panel_id = ctx.add_detached_deferred(overlay_open.clone(), panel);
470        ctx.set_dormant(panel_id);
471        self.panel_content_id = Some(panel_id);
472        // Expose `highlighted` to `accessibility()` so it can publish
473        // `set_active_descendant` pointing at the currently-highlighted
474        // suggestion row.
475        *self.highlighted_slot.borrow_mut() = Some(highlighted.clone());
476
477        let dismiss_callback: OverlayDismissCallback = {
478            let overlay_open = overlay_open.clone();
479            let dismissed = dismissed.clone();
480            let highlighted = highlighted.clone();
481            Rc::new(move |_, _| {
482                overlay_open.set(false);
483                // The dismiss arrived from the framework (Escape /
484                // outside click). Suppress re-opening until the user
485                // resumes typing or arrows back into the list — same
486                // semantics the explicit Escape handler used to have.
487                dismissed.set(true);
488                highlighted.set(None);
489            })
490        };
491
492        let self_id = ctx.self_id();
493        let open_overlay: Rc<dyn Fn(&mut EventContext)> = {
494            let overlay_open = overlay_open.clone();
495            let suggestions_open = suggestions.clone();
496            let dismissed_open = dismissed.clone();
497            let dismiss_callback = dismiss_callback.clone();
498            Rc::new(move |ctx: &mut EventContext| {
499                if overlay_open.get() {
500                    return;
501                }
502                if dismissed_open.get() || suggestions_open.get().is_empty() {
503                    return;
504                }
505                overlay_open.set(true);
506                // Activate the dormant panel BEFORE queueing the
507                // overlay request — `ctx.activate` enqueues a
508                // `TreeMutation::Activate` which is applied by the
509                // dispatch path *before* `overlay_requests` is
510                // drained, so by the time layout walks the overlay
511                // stack the panel is active and gets laid out.
512                // Without this the panel stays dormant from `build()`
513                // (we `set_dormant` it there), `show_overlay` only
514                // pushes onto the stack, and `layout_impl`'s overlay
515                // loop skips dormant content — popup never paints.
516                // ComboBox does the same dance in its `open_overlay` helper.
517                // Build the panel if this is its first open — `activate` alone
518                // would wake a node whose subtree does not exist yet.
519                ctx.materialize_now(panel_id);
520                ctx.activate(panel_id);
521                ctx.show_overlay(
522                    OverlayRequest::new(
523                        panel_id,
524                        self_id,
525                        // `NearAnchor` (rather than `BelowPreferred`)
526                        // because the popover should size to the widest
527                        // suggestion, not to the field's width. `BelowPreferred`
528                        // exists for combo-box dropdowns that must be at
529                        // least as wide as their trigger — it does
530                        // `content_size.width.max(anchor.width)` in
531                        // overlay.rs's `position_overlays`. `NearAnchor`
532                        // keeps the same below/above flip behavior, the
533                        // same horizontal viewport clamp, but takes the
534                        // content's intrinsic width as-is. The
535                        // `SuggestionPanel` already reports max
536                        // (label_width + row_padding) + panel_padding as
537                        // its natural width, so the popover ends up
538                        // exactly the size of the widest item.
539                        OverlayPlacement::NearAnchor {
540                            offset: teksilo_canvas::Vec2::ZERO,
541                        },
542                        DismissBehavior::EscapeOrClickOutside,
543                    )
544                    .on_dismiss(dismiss_callback.clone()),
545                );
546            })
547        };
548
549        // ── Compose ─────────────────────────────────────────────────
550        // The visible subtree is just the TextInput now; the
551        // suggestions panel lives as an overlay anchored to this
552        // widget's own bounds via `OverlayRequest`.
553        let body_id = ctx.add(MinSize::new(0.0, 0.0).child(input_id));
554        let style = crate::styles::recipe_search_field_style::resolve_search_field_style(
555            &self.style_override,
556            ctx,
557        );
558        let cfg = teksilo_core::styles::SearchFieldStyleConfig { body: body_id };
559        let visible_root = style.make_body(&cfg, ctx);
560        self.root_child_id = Some(visible_root);
561
562        // ── Tooltip ────────────────────────────────────────────────
563        if let Some(content) = self.composite_tooltip_content.take() {
564            let delay = ctx.theme().motion.tooltip_delay_heavy;
565            crate::tooltip::attach_composite_tooltip_boxed(ctx, visible_root, content, delay);
566        } else if let Some(source) = self.rich_tooltip_source.clone() {
567            let delay = ctx.theme().motion.tooltip_delay;
568            crate::tooltip::attach_rich_tooltip_source(ctx, visible_root, source, delay);
569        } else if let Some(text) = self.tooltip_text.clone() {
570            let delay = ctx.theme().motion.tooltip_delay;
571            crate::tooltip::attach_plain_tooltip(ctx, visible_root, text, delay);
572        }
573
574        // ── Handlers ───────────────────────────────────────────────
575        //
576        // Everything runs on the **preview** pass. The bubble pass is
577        // unreachable for character input: `TextInputField.on_key`
578        // returns `EventResponse::Handled`, which stops the bubble at
579        // the framework level (see event_dispatch_impl.rs's bubble
580        // loop). The preview pass walks strict ancestors of the focus
581        // target *before* the target itself, so this handler sees
582        // every KeyDown the user types into the inner field.
583        let suggestions_for_keys = suggestions.clone();
584        let highlighted_for_keys = highlighted.clone();
585        let dismissed_for_keys = dismissed.clone();
586        let overlay_open_for_keys = overlay_open.clone();
587        let open_for_arrows = open_overlay.clone();
588        let open_for_typing = open_overlay.clone();
589        // Captures for the Space-to-select branch (a parallel of the
590        // TextInput `on_submit_fn` picker, fired from the preview pass
591        // when an item is highlighted so Space doesn't fall through to
592        // TextInputField and insert a literal space).
593        let suggestions_for_space = suggestions.clone();
594        let highlighted_for_space = highlighted.clone();
595        let dismissed_for_space = dismissed.clone();
596        let overlay_open_for_space = overlay_open.clone();
597        let text_for_space = self.text.clone();
598        let on_select_for_space = self.on_select.clone();
599        // Inline-provider fixup. The text signal isn't updated until
600        // the next frame (TextInputField defers via
601        // `deferred_text_update`), and even on subsequent keystrokes
602        // the preview pass fires *before* the target's key handler,
603        // so `self.text.get()` is always the pre-keystroke value here.
604        // We project the post-keystroke text by appending the event's
605        // `text` field and run the provider synchronously so the
606        // popup opens with the right list on the very first character.
607        let text_for_typing = self.text.clone();
608        let provider_for_typing = self.suggestion_provider.clone();
609        let min_chars_for_typing = self.min_chars;
610        let max_for_typing = self.max_suggestions;
611
612        let handlers = HandlerSet::new().on_key_preview(move |event, ctx| -> EventResponse {
613            match event {
614                WidgetEvent::KeyDown {
615                    key: Key::ArrowDown,
616                    ..
617                } => {
618                    let list_len = suggestions_for_keys.get().len();
619                    if list_len == 0 {
620                        return EventResponse::Ignored;
621                    }
622                    // Re-open if the user previously dismissed.
623                    dismissed_for_keys.set(false);
624                    let next = match highlighted_for_keys.get() {
625                        None => 0,
626                        Some(i) if i + 1 >= list_len => 0,
627                        Some(i) => i + 1,
628                    };
629                    highlighted_for_keys.set(Some(next));
630                    open_for_arrows(ctx);
631                    EventResponse::Handled
632                }
633                WidgetEvent::KeyDown {
634                    key: Key::Space, ..
635                } if overlay_open_for_space.get() && highlighted_for_space.get().is_some() => {
636                    // Space-to-select: only kicks in when the popover
637                    // is open AND a row is currently highlighted (i.e.
638                    // the user navigated with arrow keys). Otherwise
639                    // we return Ignored so Space falls through to
640                    // TextInputField and inserts a literal space in
641                    // the query — Space is a valid search character.
642                    let idx = highlighted_for_space.get().expect("guard above");
643                    let list = suggestions_for_space.get();
644                    if let Some(value) = list.get(idx).cloned() {
645                        text_for_space.set(value.clone());
646                        if let Some(handler) = &on_select_for_space {
647                            handler(&value, ctx);
648                        }
649                        dismissed_for_space.set(true);
650                        ctx.dismiss_all_except_hosts();
651                    }
652                    EventResponse::Handled
653                }
654                WidgetEvent::KeyDown {
655                    key: Key::ArrowUp, ..
656                } => {
657                    let list_len = suggestions_for_keys.get().len();
658                    if list_len == 0 {
659                        return EventResponse::Ignored;
660                    }
661                    dismissed_for_keys.set(false);
662                    let prev = match highlighted_for_keys.get() {
663                        None => list_len - 1,
664                        Some(0) => list_len - 1,
665                        Some(i) => i - 1,
666                    };
667                    highlighted_for_keys.set(Some(prev));
668                    open_for_arrows(ctx);
669                    EventResponse::Handled
670                }
671                WidgetEvent::KeyDown { text, .. } => {
672                    // Any other key — character input, Backspace,
673                    // Delete, etc — clears the dismissed flag so the
674                    // popup can re-appear after the user resumes typing.
675                    dismissed_for_keys.set(false);
676                    // For character input, project the post-keystroke
677                    // text and run the provider synchronously, then
678                    // open. The framework continues the preview pass
679                    // (we return Ignored), so TextInputField still
680                    // gets the keystroke and inserts the character.
681                    //
682                    // Filter control characters out of the projected
683                    // text. Without this, Enter (`"\n"` / `"\r"`)
684                    // appends a newline to the prefix, the provider
685                    // returns no matches, and the empty-dismiss path
686                    // below fires `on_dismiss` synchronously between
687                    // this handler and TextInputField's submit
688                    // handler — which resets `highlighted` to None,
689                    // so submit can't pick the highlighted row. Same
690                    // for Tab, Escape, Backspace's "\u{8}", etc.
691                    let mut became_empty = false;
692                    if let (Some(ch), Some(provider)) = (text, &provider_for_typing) {
693                        let clean: String = ch.chars().filter(|c| !c.is_control()).collect();
694                        if !clean.is_empty() {
695                            let projected = format!("{}{}", text_for_typing.get(), clean);
696                            if projected.chars().count() >= min_chars_for_typing {
697                                let mut fresh = provider(&projected);
698                                if fresh.len() > max_for_typing {
699                                    fresh.truncate(max_for_typing);
700                                }
701                                became_empty = fresh.is_empty();
702                                suggestions_for_keys.set(fresh);
703                            } else {
704                                became_empty = true;
705                                suggestions_for_keys.set(Vec::new());
706                            }
707                        }
708                    }
709                    if became_empty && overlay_open_for_keys.get() {
710                        // No matches for the new text — close the
711                        // popover instead of leaving it stuck on the
712                        // pre-keystroke list. The framework's dismiss
713                        // path fires the on_dismiss callback we
714                        // registered, which resets `overlay_open` and
715                        // `dismissed` correctly. The next character
716                        // that yields a non-empty list will reopen.
717                        ctx.dismiss_all_except_hosts();
718                    } else {
719                        open_for_typing(ctx);
720                    }
721                    EventResponse::Ignored
722                }
723                _ => EventResponse::Ignored,
724            }
725        });
726
727        ctx.apply_self_handlers(handlers);
728
729        // Return BOTH the visible root AND the dormant suggestions
730        // panel as children so the framework links `panel_id` under
731        // this widget in the arena instead of leaving it an orphan
732        // root. See popover_widget.rs for the same pattern.
733        let mut out = vec![visible_root];
734        if let Some(panel_id) = self.panel_content_id {
735            out.push(panel_id);
736        }
737        out
738    }
739
740    fn layout_response(
741        &self,
742        proposal: SizeProposal,
743        ctx: &LayoutContext,
744    ) -> teksilo_core::widget::LayoutResponse {
745        self.root_child_id
746            .and_then(|id| ctx.child_size(id, proposal))
747            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
748            .into()
749    }
750
751    fn place_children(
752        &self,
753        bounds: Rect,
754        _proposal: SizeProposal,
755        children: &mut [WidgetPlacement],
756        _ctx: &LayoutContext,
757    ) {
758        // The visible root fills our bounds; the suggestions panel's
759        // bounds are owned by the overlay manager when shown
760        // (`position_overlays`), so we zero-size it here.
761        for child in children.iter_mut() {
762            if Some(child.id) == self.panel_content_id {
763                child.size = teksilo_canvas::Size::ZERO;
764                continue;
765            }
766            child.origin = bounds.origin();
767            child.size = bounds.size();
768        }
769    }
770
771    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
772        use teksilo_core::accessibility::widget_id_to_node_id;
773        builder.set_role(teksilo_core::accesskit::Role::SearchInput);
774        builder.set_has_popup(teksilo_core::accesskit::HasPopup::Listbox);
775        builder.set_auto_complete(teksilo_core::accesskit::AutoComplete::List);
776        // `set_expanded` so AT clients know the popup is open. Set
777        // both true and false explicitly — without the false branch
778        // the field carries a stale `expanded=true` after dismiss.
779        let is_open = self
780            .overlay_open
781            .borrow()
782            .as_ref()
783            .is_some_and(|sig| sig.get());
784        builder.set_expanded(is_open);
785        if let Some(listbox_id) = self.listbox_id_slot.get() {
786            builder.push_controlled(widget_id_to_node_id(listbox_id));
787        }
788        // ARIA combobox pattern: focus stays on the search input
789        // while arrow keys navigate the listbox; screen readers
790        // follow `aria-activedescendant` to announce the currently-
791        // highlighted option. Without this, ArrowUp/ArrowDown in the
792        // popover are silent to AT users.
793        if is_open
794            && let Some(sig) = self.highlighted_slot.borrow().as_ref()
795            && let Some(idx) = sig.get()
796        {
797            let row_ids = self.row_ids_slot.borrow();
798            if let Some(&row_id) = row_ids.get(idx) {
799                builder
800                    .inner_mut()
801                    .set_active_descendant(widget_id_to_node_id(row_id));
802            }
803        }
804    }
805
806    fn children(&self) -> Vec<WidgetId> {
807        let mut out = Vec::new();
808        if let Some(id) = self.root_child_id {
809            out.push(id);
810        }
811        if let Some(id) = self.panel_content_id {
812            out.push(id);
813        }
814        out
815    }
816}
817
818// ── SuggestionPanel — the in-tree listbox rendered below the field ─
819
820struct SuggestionPanel {
821    text: Signal<String>,
822    suggestions: Signal<Vec<String>>,
823    highlighted: Signal<Option<usize>>,
824    on_select: Option<OnSelect>,
825    dismissed: Signal<bool>,
826    listbox_id_slot: Rc<Cell<Option<WidgetId>>>,
827    /// SearchField-side slot we populate with the current row WidgetIds
828    /// in display order, so its `accessibility()` can map `highlighted`
829    /// back to the active row id for `set_active_descendant`.
830    row_ids_slot: Rc<RefCell<Vec<WidgetId>>>,
831    root_child_id: Option<WidgetId>,
832}
833
834impl std::fmt::Debug for SuggestionPanel {
835    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836        f.debug_struct("SuggestionPanel").finish_non_exhaustive()
837    }
838}
839
840impl Widget for SuggestionPanel {
841    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
842        use teksilo_core::binding::BindingLevel;
843        // Bind the panel to `suggestions` at `Rebuild` so its row
844        // list refreshes whenever the Vec changes — same pattern
845        // `Repeater` uses for ListModel-backed dynamic content.
846        self.suggestions
847            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
848
849        use crate::styles::recipe_search_field_style as sf;
850        let suggestions = self.suggestions.clone();
851        let highlighted = self.highlighted.clone();
852        let on_select = self.on_select.clone();
853        let text = self.text.clone();
854        let dismissed = self.dismissed.clone();
855
856        // Row gutters come from the active `SearchFieldStyle`, not from
857        // `sf::ROW_PADDING_*`: a preset writes its own on the recipe (macOS's
858        // `NSSearchField` results row is 8 x 3 dp) and reading the module
859        // constants here rendered 10 x 4 whatever the theme had decided.
860        let panel_style =
861            crate::styles::recipe_search_field_style::resolve_search_field_style(&None, ctx);
862        let panel_tokens = ctx.theme().input;
863        let row_padding_horizontal = panel_style.row_padding_horizontal(&panel_tokens);
864        let row_padding_vertical = panel_style.row_padding_vertical(&panel_tokens);
865
866        let list = suggestions.get();
867        let total = list.len();
868        let mut row_ids: Vec<WidgetId> = Vec::with_capacity(total);
869        let mut column = VStack::new().alignment(HAlignment::Leading);
870        for (idx, value) in list.into_iter().enumerate() {
871            let bg_role = highlighted.map(move |h| match h {
872                Some(i) if *i == idx => SurfaceRole::Hover,
873                _ => SurfaceRole::Transparent,
874            });
875            let bg = ctx.add(
876                RectWidget::new()
877                    .background(bg_role)
878                    .corner_radius(CornerRadius::uniform(sf::ROW_CORNER_RADIUS)),
879            );
880            let label_id = ctx.add(
881                TextWidget::new(lit!(&value))
882                    .style(TextStyleRole::Body)
883                    .single_line()
884                    // `SuggestionRow::accessibility` already carries `value`
885                    // as the `Role::ListBoxOption` node's own name.
886                    .a11y_hidden(),
887            );
888            let inner_padded = ctx.add(
889                Padding::symmetric(row_padding_vertical, row_padding_horizontal).child(label_id),
890            );
891            let row_z = ctx.add(ZStack::new().child(bg).child(inner_padded));
892
893            let value_for_tap = value.clone();
894            let on_select_for_tap = on_select.clone();
895            let text_for_tap = text.clone();
896            let highlighted_for_hover = highlighted.clone();
897            let dismissed_for_tap = dismissed.clone();
898            let row = ctx.add(
899                SuggestionRow {
900                    label: value.clone(),
901                    index: idx,
902                    row_height: sf::ROW_HEIGHT,
903                    selected_signal: highlighted.clone(),
904                    inner_id: row_z,
905                }
906                .on_tap(move |_pos, ctx| {
907                    text_for_tap.set(value_for_tap.clone());
908                    if let Some(handler) = &on_select_for_tap {
909                        handler(&value_for_tap, ctx);
910                    }
911                    dismissed_for_tap.set(true);
912                    // Close the popover after picking a suggestion.
913                    // The on_dismiss callback resets `overlay_open`
914                    // and re-sets `dismissed` (idempotent — we just
915                    // set it ourselves).
916                    ctx.dismiss_all_except_hosts();
917                })
918                .on_hover(move |entered, _| {
919                    if entered {
920                        highlighted_for_hover.set(Some(idx));
921                    }
922                })
923                .cursor(CursorIcon::Pointer),
924            );
925            row_ids.push(row);
926            column = column.child(row);
927        }
928        // Publish the row ids for SearchField's `accessibility()` to
929        // resolve `highlighted -> active_descendant`. Repopulated on
930        // every rebuild so it tracks the live row WidgetIds.
931        *self.row_ids_slot.borrow_mut() = row_ids;
932
933        // Listbox surface — routed through `PopoverStyle` (the
934        // `Menu`-flavoured variant), so the panel background, border,
935        // corner radius, and the field-attached drop shadow are owned
936        // by the active popover style. The suggestion popup normally
937        // opens below the field (`NearAnchor` in `SearchField`, which
938        // flips above only when there is no room below), so the
939        // placement handed to the style suppresses the top-side shadow.
940        let listbox_inner = ctx.add(column);
941        let padded = ctx.add(Padding::uniform(sf::PANEL_PADDING).child(listbox_inner));
942        let popover_style: teksilo_core::styles::SharedPopoverStyle =
943            ctx.theme().style_slots.popover.clone().unwrap_or_else(|| {
944                Rc::new(crate::styles::RecipePopoverStyle::for_tokens(
945                    &ctx.theme().input,
946                ))
947            });
948        let surface = popover_style.make_body(
949            &PopoverStyleConfig {
950                content: padded,
951                variant: PopoverVariant::Menu,
952                name: String::new(),
953                placement: OverlayPlacement::BelowPreferred,
954                show_caret: false,
955                caret_size: 0.0,
956            },
957            ctx,
958        );
959
960        let listbox = ctx.add(SuggestionListBox {
961            inner: surface,
962            total,
963        });
964        // Publish the listbox WidgetId so SearchField's a11y can wire
965        // `aria-controls`.
966        self.listbox_id_slot.set(Some(listbox));
967
968        self.root_child_id = Some(listbox);
969        vec![listbox]
970    }
971
972    fn layout_response(
973        &self,
974        proposal: SizeProposal,
975        ctx: &LayoutContext,
976    ) -> teksilo_core::widget::LayoutResponse {
977        self.root_child_id
978            .and_then(|id| ctx.child_size(id, proposal))
979            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
980            .into()
981    }
982
983    fn place_children(
984        &self,
985        bounds: Rect,
986        _proposal: SizeProposal,
987        children: &mut [WidgetPlacement],
988        _ctx: &LayoutContext,
989    ) {
990        for child in children.iter_mut() {
991            child.origin = bounds.origin();
992            child.size = bounds.size();
993        }
994    }
995
996    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
997        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
998    }
999
1000    fn children(&self) -> Vec<WidgetId> {
1001        self.root_child_id.into_iter().collect()
1002    }
1003}
1004
1005// ── ListBox a11y wrapper around the styled column ─────────────────
1006
1007struct SuggestionListBox {
1008    inner: WidgetId,
1009    /// How many suggestions the query returned — the one number a screen-reader
1010    /// user needs before deciding whether to arrow through them.
1011    ///
1012    /// On the container, not on each row: AccessKit's `size_of_set` belongs on
1013    /// the container (unlike ARIA's per-item `aria-setsize`), and
1014    /// `size_of_set_from_container` walks *up* from an item to find it, so a
1015    /// count written on a `Role::ListBoxOption` is read by no adapter.
1016    total: usize,
1017}
1018
1019impl std::fmt::Debug for SuggestionListBox {
1020    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1021        f.debug_struct("SuggestionListBox").finish_non_exhaustive()
1022    }
1023}
1024
1025impl Widget for SuggestionListBox {
1026    fn layout_response(
1027        &self,
1028        proposal: SizeProposal,
1029        ctx: &LayoutContext,
1030    ) -> teksilo_core::widget::LayoutResponse {
1031        ctx.child_size(self.inner, proposal)
1032            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1033            .into()
1034    }
1035
1036    fn place_children(
1037        &self,
1038        bounds: Rect,
1039        _proposal: SizeProposal,
1040        children: &mut [WidgetPlacement],
1041        _ctx: &LayoutContext,
1042    ) {
1043        for child in children.iter_mut() {
1044            child.origin = bounds.origin();
1045            child.size = bounds.size();
1046        }
1047    }
1048
1049    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1050        builder.set_role(teksilo_core::accesskit::Role::ListBox);
1051        if self.total > 0 {
1052            builder.set_size_of_set(self.total);
1053        }
1054    }
1055
1056    fn children(&self) -> Vec<WidgetId> {
1057        vec![self.inner]
1058    }
1059}
1060
1061// ── Per-row a11y wrapper: Role::ListBoxOption with ARIA position
1062//    metadata. Bridges tap / hover handlers onto the styled inner
1063//    ZStack via WidgetBuilder. ─────────────────────────────────────
1064
1065struct SuggestionRow {
1066    label: String,
1067    index: usize,
1068    /// Minimum row height — the `recipe_search_field_style::ROW_HEIGHT`
1069    /// module constant, read at build time.
1070    row_height: f32,
1071    selected_signal: Signal<Option<usize>>,
1072    inner_id: WidgetId,
1073}
1074
1075impl std::fmt::Debug for SuggestionRow {
1076    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1077        f.debug_struct("SuggestionRow")
1078            .field("label", &self.label)
1079            .field("index", &self.index)
1080            .finish()
1081    }
1082}
1083
1084impl Widget for SuggestionRow {
1085    fn layout_response(
1086        &self,
1087        proposal: SizeProposal,
1088        ctx: &LayoutContext,
1089    ) -> teksilo_core::widget::LayoutResponse {
1090        let inner = ctx
1091            .child_size(self.inner_id, proposal)
1092            .unwrap_or_else(|| proposal.resolve(0.0, self.row_height));
1093        let height = inner.height.max(self.row_height);
1094        let width = proposal.width.unwrap_or(inner.width).max(inner.width);
1095        teksilo_canvas::Size::new(width, height).into()
1096    }
1097
1098    fn place_children(
1099        &self,
1100        bounds: Rect,
1101        _proposal: SizeProposal,
1102        children: &mut [WidgetPlacement],
1103        _ctx: &LayoutContext,
1104    ) {
1105        for child in children.iter_mut() {
1106            child.origin = bounds.origin();
1107            child.size = bounds.size();
1108        }
1109    }
1110
1111    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1112        builder.set_role(teksilo_core::accesskit::Role::ListBoxOption);
1113        builder.set_name(&self.label);
1114        let is_selected = self.selected_signal.get() == Some(self.index);
1115        builder.set_selected(is_selected);
1116        builder.set_position_in_set(self.index + 1);
1117        // The "of N" half lives on the `SuggestionListBox` container; a count
1118        // written here would be read by no adapter.
1119    }
1120
1121    fn children(&self) -> Vec<WidgetId> {
1122        vec![self.inner_id]
1123    }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use teksilo_core::widget_tree::WidgetTree;
1130
1131    #[test]
1132    fn search_field_builds() {
1133        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1134        let q = Signal::new(String::new());
1135        let id = tree.add(SearchField::new(q.clone()).placeholder(lit!("Search docs")));
1136        tree.layout(SizeProposal {
1137            width: Some(320.0),
1138            height: None,
1139        });
1140        let b = tree.bounds(id);
1141        assert!(b.width > 0.0);
1142        assert!(b.height > 0.0);
1143    }
1144
1145    #[test]
1146    fn search_field_a11y_role() {
1147        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1148        let id = tree.add(
1149            SearchField::new(Signal::new(String::new())).with_suggestions(|q| {
1150                if q.is_empty() {
1151                    Vec::new()
1152                } else {
1153                    vec!["alpha".into(), "beta".into()]
1154                }
1155            }),
1156        );
1157        tree.layout(SizeProposal {
1158            width: Some(280.0),
1159            height: None,
1160        });
1161        let info = tree.accessibility_node(id);
1162        assert_eq!(info.role(), teksilo_core::accesskit::Role::SearchInput);
1163    }
1164
1165    #[test]
1166    fn tooltip_appears_on_hover() {
1167        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1168        let q = Signal::new(String::new());
1169        let id = tree.add(SearchField::new(q.clone()).tooltip(lit!("Tip")));
1170        tree.layout(SizeProposal::exact(300.0, 200.0));
1171        tree.pointer_move(tree.bounds(id).center());
1172        tree.advance_time(std::time::Duration::from_secs(1));
1173        assert_eq!(
1174            tree.active_overlays().len(),
1175            1,
1176            "tooltip should appear on hover"
1177        );
1178        assert!(tree.find_by_label("Tip").is_some());
1179    }
1180
1181    #[test]
1182    fn suggestions_provider_runs_on_text_change() {
1183        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184        let q = Signal::new(String::new());
1185        let calls: Rc<Cell<usize>> = Rc::new(Cell::new(0));
1186        let calls_clone = calls.clone();
1187        tree.add(SearchField::new(q.clone()).with_suggestions(move |s| {
1188            calls_clone.set(calls_clone.get() + 1);
1189            if s.is_empty() {
1190                Vec::new()
1191            } else {
1192                vec!["alpha".into()]
1193            }
1194        }));
1195        tree.layout(SizeProposal::exact(300.0, 100.0));
1196        let baseline = calls.get();
1197        q.set("a".to_string());
1198        tree.layout(SizeProposal::exact(300.0, 100.0));
1199        assert!(
1200            calls.get() > baseline,
1201            "provider should fire on text change"
1202        );
1203    }
1204
1205    #[test]
1206    fn suggestion_row_label_does_not_duplicate_the_listbox_options_name() {
1207        // `SuggestionRow::accessibility` already carries `value` as the
1208        // `Role::ListBoxOption` node's own name, so the embedded Label
1209        // built for the row's text must not survive as a second,
1210        // duplicate-named stop.
1211        let q = Signal::new(String::new());
1212        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1213        let id = tree.add(SearchField::new(q.clone()).with_suggestions(|s| {
1214            if s.is_empty() {
1215                Vec::new()
1216            } else {
1217                vec!["alpha".into()]
1218            }
1219        }));
1220        tree.layout(SizeProposal::exact(300.0, 200.0));
1221
1222        let field = tree
1223            .first_focusable_descendant(id)
1224            .expect("SearchField should have a focusable inner input");
1225        tree.focus(field);
1226        tree.type_text(id, "a");
1227        tree.layout(SizeProposal::exact(300.0, 200.0));
1228
1229        let update = tree.sync_accessibility();
1230
1231        let row_node = update
1232            .nodes
1233            .iter()
1234            .find(|(_, node)| node.role() == teksilo_core::accesskit::Role::ListBoxOption)
1235            .map(|(_, node)| node)
1236            .expect("suggestion row should be in the AT tree once the popover is open");
1237        assert_eq!(
1238            row_node.label(),
1239            Some("alpha"),
1240            "hiding the embedded label must not take the row's own name away with it",
1241        );
1242
1243        assert!(
1244            !update.nodes.iter().any(|(_, node)| node.role()
1245                == teksilo_core::accesskit::Role::Label
1246                && node.label() == Some("alpha")),
1247            "the row's Text widget must not survive as a duplicate-named AT node",
1248        );
1249    }
1250}