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