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