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}