Skip to main content

teksilo_widgets/
combo_box.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ComboBox — dropdown selection widget.
5//!
6//! Generic over the item type `T: Clone + PartialEq + 'static`. Selection is
7//! value-based: the bound `Signal<Option<T>>` survives reorder and insertion
8//! of the backing model. Items come from one of four input paths:
9//!
10//! - [`ComboBox::new`] — static list of localizable strings (the 90% case).
11//! - [`ComboBox::from_items`] — static list of typed values.
12//! - [`ComboBox::from_model`] — reactive [`ListModel<T>`].
13//! - [`ComboBox::from_source`] — external [`ListDataSource<Item = T>`].
14//!
15//! The dropdown panel's node is created during `build()` and kept dormant, but
16//! its subtree is deferred — built the first time the combo is opened.
17//!
18//! # Keyboard
19//!
20//! - `Enter` / `Space` — toggle the list.
21//! - `ArrowDown` / `ArrowUp` — open the list *and* move the selection one
22//!   item, stopping at the ends. Win32's combo box, `QComboBox`, GTK and the
23//!   W3C ARIA listbox pattern all stop rather than wrap; a combo box is a
24//!   value, and wrapping is the menu convention.
25//! - `Alt+ArrowDown` — open the list **without** moving the selection, and
26//!   `Alt+ArrowUp` — close it. The Win32 / WinForms / WPF chord and the ARIA
27//!   combobox pattern.
28//! - `F4` — toggle the list (Win32 / Qt / WPF).
29//! - `Home` / `End` — first / last item.
30//! - `PageUp` / `PageDown` — one page, where a page is
31//!   [`max_visible_items`](ComboBox::max_visible_items) rows.
32//! - Printable characters — type-ahead, within
33//!   [`type_ahead_timeout`](ComboBox::type_ahead_timeout).
34//! - A chord holding `Ctrl`, `Alt` or `Super` is not the combo box's and falls
35//!   through to the application; `Shift` is, so a capital letter still types.
36//!
37//! The widget is split across four internal modules:
38//! - `state` holds the `ItemSource` accessor, the default
39//!   `max_visible_items` constant, and the index helpers.
40//! - `item` holds the single-row `DropdownItem` widget.
41//! - `panel` holds the `DropdownPanel` overlay content and the
42//!   `FilteredItemList` inner widget.
43//! - `tests` holds the headless unit tests.
44//!
45//! ## Touch and pen
46//!
47//! The closed box is one target — the arrow column is paint inside it, not a
48//! second target — and it opens the dropdown from its tap, so already on the
49//! release. The field's hover tint is decoration with nothing behind it.
50//!
51//! The dropdown's rows are menu rows and take the menu row's target floor at every
52//! density; before this they measured against the raw Compact constant while a
53//! `MenuList`'s rows grew, and the panel that shows `max_visible_items` of them now
54//! resolves the same number they do.
55
56use std::cell::{Cell, RefCell};
57use std::rc::Rc;
58use std::time::{Duration, Instant};
59use teksilo_i18n::lit;
60
61use teksilo_canvas::{Rect, Size, SizeProposal};
62use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::event::{EventResponse, Key, WidgetEvent};
65use teksilo_core::overlay::{
66    DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
67};
68use teksilo_core::signal::{Prop, Signal};
69use teksilo_core::styles::{ComboBoxStyle, ComboBoxStyleConfig, SharedComboBoxStyle};
70use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
71use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
72use teksilo_core::widget_id::WidgetId;
73use teksilo_data::{DataChange, ListDataSource, ListModel};
74use teksilo_tokens::{TextRole, TextStyleRole};
75
76use crate::common::range_nav;
77use crate::primitives::TextWidget;
78
79mod item;
80mod panel;
81mod state;
82
83#[cfg(test)]
84mod tests;
85
86use self::panel::DropdownPanel;
87use self::state::{DEFAULT_MAX_VISIBLE_ITEMS, ItemSource, resolve_index};
88
89// Re-export so callers can write `ComboBox::new(...).variant(ComboBoxVariant::Filled)`
90// without reaching into `teksilo::core::styles`.
91pub use teksilo_core::styles::ComboBoxVariant;
92use teksilo_i18n::LocalizedString;
93
94/// A dropdown selection widget.
95///
96/// ```ignore
97/// // Simple: list of strings.
98/// let selected = ctx.signal(None::<String>);
99/// ComboBox::new(["Apple", "Banana", "Cherry"], selected)
100///     .placeholder(lit!("Select a fruit..."))
101///
102/// // Typed items: any T: Clone + PartialEq, plus a label extractor.
103/// #[derive(Clone, PartialEq)] struct Fruit { name: String, emoji: &'static str }
104/// let selected = ctx.signal(None::<Fruit>);
105/// ComboBox::from_items(fruits, selected)
106///     .item_label(|f: &Fruit| lit!(format!("{} {}", f.emoji, f.name)))
107///
108/// // Model-backed: reactive.
109/// let model = ListModel::from_vec(fruits);
110/// ComboBox::from_model(model, selected)
111///     .item_label(|f: &Fruit| lit!(f.name.clone()))
112///     .max_visible_items(6)
113/// ```
114pub struct ComboBox<T: Clone + PartialEq + 'static> {
115    source: ItemSource<T>,
116    selected: Signal<Option<T>>,
117    item_label: Rc<dyn Fn(&T) -> LocalizedString>,
118    render_item: Option<Rc<dyn Fn(&T, bool) -> Box<dyn Widget>>>,
119    /// Optional custom renderer for the *trigger's selected value* (the
120    /// widget shown when the combo is closed). When set, the closed combo
121    /// shows this widget for the current selection instead of the plain
122    /// text label — e.g. a `FontPicker` rendering the chosen family in its
123    /// own typeface. Rebuilt on every selection change (see
124    /// [`render_selected`](Self::render_selected)).
125    render_selected: Option<Rc<dyn Fn(&T) -> Box<dyn Widget>>>,
126    /// Optional callback fired whenever the user commits a selection —
127    /// from a dropdown-row tap or keyboard pick — with a live
128    /// `EventContext`. Distinct from observing the `selected` signal:
129    /// it provides the `EventContext` needed for context-bearing actions
130    /// (navigation, `set_locale`, opening overlays). Fires only on
131    /// user-driven commits, not on external writes to `selected`.
132    on_select: Option<Rc<dyn Fn(&T, &mut EventContext)>>,
133    placeholder: LocalizedString,
134    /// Accessible label — independent of placeholder and current selection.
135    /// Screen readers announce this as the name of the control.
136    label: Option<LocalizedString>,
137    /// Enabled state, static or reactive; forwarded to the arena at
138    /// build time.
139    enabled: Prop<bool>,
140    max_visible_items: usize,
141    /// Type-ahead reset window: keystrokes more than this far apart start a
142    /// fresh prefix instead of extending the previous one. Mirrors
143    /// `MenuList::type_ahead_timeout`. A `Duration::ZERO` makes every
144    /// keystroke independent (used by tests).
145    type_ahead_timeout: Duration,
146    /// When `true`, the dropdown panel includes a search field at the top
147    /// and the list is filtered live against the query.
148    searchable: bool,
149    /// Custom match predicate used in searchable mode. If unset, the
150    /// default is a case-insensitive substring match on the label.
151    filter: Option<Rc<dyn Fn(&str, &T) -> bool>>,
152    /// Search query signal, created lazily on the first build when
153    /// `searchable` is enabled. Shared with the `DropdownPanel` so both
154    /// the trigger-side a11y state and the panel's filter see the same
155    /// value.
156    search_query: Option<Signal<String>>,
157    /// Cached index of the currently-selected value in `source`. Validated
158    /// on every read; a miss triggers a fresh O(n) scan. Shared across the
159    /// keyboard handler and the label-derive closure so both benefit from
160    /// the cache across selection changes.
161    selected_index_hint: Rc<Cell<Option<usize>>>,
162    /// Tier-1 design-language variant. The active `ComboBoxStyle`
163    /// decides how to paint each variant; IntUI's default ships
164    /// `Outlined` (bordered), `Filled` (tinted fill, no border) and
165    /// `Plain` (chrome-less) out of the box, with `Underline` still
166    /// painted as `Outlined`.
167    variant: ComboBoxVariant,
168    /// Per-call style override.
169    style_override: Option<SharedComboBoxStyle>,
170    /// Per-call override for the selected-value text style (font, size,
171    /// weight). `None` ⇒ the default `TextStyleRole::Body`.
172    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
173    /// Per-call override for the selected-value text color. `None` ⇒
174    /// enabled-derived (`Primary` / `Disabled`); setting this replaces it.
175    text_role_override: Option<teksilo_core::color_prop::ColorProp>,
176    /// Optional plain tooltip text shown after a hover delay.
177    /// Mutually exclusive with `rich_tooltip_source` and
178    /// `composite_tooltip_content` — every tooltip setter clears the
179    /// other two so last-call wins.
180    tooltip_text: Option<LocalizedString>,
181    /// Optional rich tooltip source (registry key or inline content).
182    /// Mutually exclusive with `tooltip_text` and
183    /// `composite_tooltip_content` per the last-call-wins matrix.
184    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
185    /// Optional composite tooltip body. Hosts an arbitrary widget tree
186    /// (charts, grids, conditional rows). Mutually exclusive with
187    /// `tooltip_text` and `rich_tooltip_source`.
188    composite_tooltip_content: Option<Box<dyn Widget>>,
189    // Build state — four mutable signals replace the legacy
190    // `ComboBoxState` enum. `is_open` survives until the dropdown
191    // dismisses (overlay callback resets it); `is_focused` /
192    // `is_hovered` flip on the corresponding handlers; `is_disabled`
193    // mirrors the arena's *effective* enabled state reactively (an
194    // effect on `ctx.effective_enabled_signal`, so an ancestor's
195    // disablement reaches it too).
196    is_open: Signal<bool>,
197    is_hovered: Signal<bool>,
198    is_focused: Signal<bool>,
199    is_disabled: Signal<bool>,
200    root_child_id: Option<WidgetId>,
201    dropdown_content_id: Option<WidgetId>,
202}
203
204impl ComboBox<String> {
205    /// Create a ComboBox from a list of strings.
206    ///
207    /// Accepts any `impl Into<String>` — string literals (`&str`),
208    /// owned `String`s, resolved `LocalizedString`s, etc. For
209    /// translated items, resolve translations before passing in,
210    /// e.g. `vec![tr!(apple()).resolve_now(), ...]`.
211    pub fn new(
212        items: impl IntoIterator<Item = impl Into<String>>,
213        selected: Signal<Option<String>>,
214    ) -> Self {
215        let items: Vec<String> = items.into_iter().map(Into::into).collect();
216        Self::new_with_item_source(
217            ItemSource::from_vec(items),
218            selected,
219            Rc::new(|s: &String| LocalizedString::literal(s.clone())),
220        )
221    }
222}
223
224impl<T: Clone + PartialEq + 'static> ComboBox<T> {
225    fn new_with_item_source(
226        source: ItemSource<T>,
227        selected: Signal<Option<T>>,
228        item_label: Rc<dyn Fn(&T) -> LocalizedString>,
229    ) -> Self {
230        Self {
231            source,
232            selected,
233            item_label,
234            render_item: None,
235            render_selected: None,
236            on_select: None,
237            placeholder: LocalizedString::literal(String::new()),
238            label: None,
239            enabled: Prop::Static(true),
240            max_visible_items: DEFAULT_MAX_VISIBLE_ITEMS,
241            type_ahead_timeout: Duration::from_millis(500),
242            searchable: false,
243            filter: None,
244            search_query: None,
245            variant: ComboBoxVariant::default(),
246            style_override: None,
247            label_style: None,
248            text_role_override: None,
249            tooltip_text: None,
250            rich_tooltip_source: None,
251            composite_tooltip_content: None,
252            is_open: Signal::new(false),
253            is_hovered: Signal::new(false),
254            is_focused: Signal::new(false),
255            is_disabled: Signal::new(false),
256            root_child_id: None,
257            dropdown_content_id: None,
258            selected_index_hint: Rc::new(Cell::new(None)),
259        }
260    }
261
262    /// Static list of typed items. `item_label` is the display extractor —
263    /// it's required at construction so the compiler enforces it rather
264    /// than a runtime check. For `T = String`, use [`ComboBox::new`] which
265    /// defaults to the identity label.
266    pub fn from_items<F>(
267        items: impl IntoIterator<Item = T>,
268        selected: Signal<Option<T>>,
269        item_label: F,
270    ) -> Self
271    where
272        F: Fn(&T) -> LocalizedString + 'static,
273    {
274        Self::new_with_item_source(
275            ItemSource::from_vec(items.into_iter().collect()),
276            selected,
277            Rc::new(item_label),
278        )
279    }
280
281    /// Backed by a reactive [`ListModel<T>`]. Inserts, removes, and reorders
282    /// propagate into the dropdown automatically. If the currently-selected
283    /// value disappears from the model, `selected` becomes `None`.
284    pub fn from_model<F>(model: ListModel<T>, selected: Signal<Option<T>>, item_label: F) -> Self
285    where
286        F: Fn(&T) -> LocalizedString + 'static,
287    {
288        Self::new_with_item_source(ItemSource::from_model(model), selected, Rc::new(item_label))
289    }
290
291    /// Backed by a custom [`ListDataSource`] — for external or paged data.
292    pub fn from_source<S, F>(source: S, selected: Signal<Option<T>>, item_label: F) -> Self
293    where
294        S: ListDataSource<Item = T> + 'static,
295        F: Fn(&T) -> LocalizedString + 'static,
296    {
297        Self::new_with_item_source(
298            ItemSource::from_data_source(source),
299            selected,
300            Rc::new(item_label),
301        )
302    }
303
304    /// Override the display-label extractor. Rarely needed — prefer passing
305    /// `item_label` to the constructor. Useful for the `ComboBox<String>`
306    /// path when you want a non-identity projection.
307    pub fn item_label(mut self, f: impl Fn(&T) -> LocalizedString + 'static) -> Self {
308        self.item_label = Rc::new(f);
309        self
310    }
311
312    /// Custom cell rendering. The closure receives the item and a flag
313    /// indicating whether it is the currently-selected value.
314    ///
315    /// The framework wraps the returned widget with the correct
316    /// `Role::ListBoxOption` accessibility and tap handler, so callers
317    /// do not need to manage a11y or selection dispatch themselves.
318    ///
319    /// **Reactivity.** The `bool` argument is a snapshot at build time.
320    /// If the selection flips after the dropdown is open, the user's
321    /// subtree is not automatically re-rendered; the framework-managed
322    /// highlight background (behind the custom widget) does update, and
323    /// closing and re-opening the dropdown picks up the new state. If
324    /// you need a reactive appearance that tracks selection, close over
325    /// a `Signal<Option<T>>` in your closure and compare against the
326    /// item value inside a `.map()` / `bind_*` on primitives.
327    ///
328    /// **Accessibility.** The wrapper's `set_name(label)` (from
329    /// `item_label`) is what screen readers announce. If the returned
330    /// widget includes its own text nodes (e.g. a bare `TextWidget`), the
331    /// label may be announced twice — one from the wrapper, one from the
332    /// inner text. Wrap primary text nodes in `.a11y_hidden()` to avoid
333    /// duplication, and reserve visible widgets for presentation only.
334    pub fn render_item(mut self, f: impl Fn(&T, bool) -> Box<dyn Widget> + 'static) -> Self {
335        self.render_item = Some(Rc::new(f));
336        self
337    }
338
339    /// Custom renderer for the trigger's *selected value* — the widget shown
340    /// when the combo is closed. The parallel of [`render_item`](Self::render_item)
341    /// for the trigger rather than the dropdown rows.
342    ///
343    /// When set, the closed combo shows `f(&value)` for the current
344    /// selection instead of the plain text label (`item_label`). The
345    /// canonical use is a `FontPicker` rendering the selected family name in
346    /// its own typeface. The subtree is rebuilt whenever the selection
347    /// changes and whenever the locale changes (so a `None`-state
348    /// placeholder re-translates), without rebuilding the whole ComboBox.
349    ///
350    /// **Accessibility.** The rendered subtree is excluded from the
351    /// accessibility tree — the ComboBox's own `accessibility(builder)`
352    /// already announces the selected value via `set_value`, so the custom
353    /// visual can never double-announce. When nothing is selected the
354    /// trigger shows the `placeholder` text.
355    pub fn render_selected(mut self, f: impl Fn(&T) -> Box<dyn Widget> + 'static) -> Self {
356        self.render_selected = Some(Rc::new(f));
357        self
358    }
359
360    /// Register a callback fired when the user commits a selection — by
361    /// tapping a dropdown row or picking one with the keyboard (arrows /
362    /// type-ahead / Home / End). The callback receives the chosen value
363    /// and a live [`EventContext`], so it can run context-bearing actions
364    /// that observing the bound `selected` signal cannot — e.g.
365    /// `ctx.set_locale(...)`, navigation, or opening another overlay.
366    ///
367    /// It fires **only on user-driven commits**, not on external writes
368    /// to the `selected` signal (those are observed via `ctx.effect`).
369    /// The `selected` signal is updated *before* the callback runs.
370    pub fn on_select(mut self, f: impl Fn(&T, &mut EventContext) + 'static) -> Self {
371        self.on_select = Some(Rc::new(f));
372        self
373    }
374
375    /// Maximum number of items shown before the dropdown becomes scrollable.
376    /// Defaults to 8. Clamped to at least 1.
377    pub fn max_visible_items(mut self, n: usize) -> Self {
378        self.max_visible_items = n.max(1);
379        self
380    }
381
382    /// Reset window for keyboard type-ahead. Keystrokes more than `d` apart
383    /// begin a fresh prefix; within `d` they extend it. Defaults to 500 ms,
384    /// matching [`MenuList::type_ahead_timeout`](crate::MenuList::type_ahead_timeout). Pass `Duration::ZERO` to
385    /// treat each keystroke independently.
386    pub fn type_ahead_timeout(mut self, d: Duration) -> Self {
387        self.type_ahead_timeout = d;
388        self
389    }
390
391    /// Placeholder text shown in the trigger when `selected` is `None`.
392    /// Accepts a `tr!(...)` directly (resolved at build); use
393    /// `placeholder_literal` for an
394    /// untranslated string.
395    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
396        let ls: LocalizedString = text.into();
397        self.placeholder = ls;
398        self
399    }
400
401    /// Accessible label describing what this combo box is for
402    /// (e.g. "Fruit", "Font family"). Independent of the visible
403    /// placeholder and of the current selection — screen readers
404    /// announce this as the name of the control.
405    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
406        let ls: LocalizedString = label.into();
407        self.label = Some(ls);
408        self
409    }
410
411    /// Set the enabled state, statically or reactively. Forwarded to
412    /// the arena at build time.
413    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
414        self.enabled = enabled.into();
415        self
416    }
417
418    /// Pick a Tier-1 design-language variant
419    /// ([`ComboBoxVariant::Outlined`] / `Filled` / `Underline` / `Plain`).
420    /// The active [`ComboBoxStyle`] decides what to do with the hint —
421    /// IntUI's default impl honours `Outlined` (default) and `Plain`;
422    /// a custom impl (Material 3, macOS, etc.) might paint differently.
423    pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
424        self.variant = variant;
425        self
426    }
427
428    /// Override the active [`ComboBoxStyle`] for this widget instance
429    /// only. The default IntUI chrome ([`crate::styles::RecipeComboBoxStyle`])
430    /// resolves its dimensions from `theme.input` (the density
431    /// `InputTokens`); custom impls
432    /// can paint anything they want around the selected-label slot.
433    pub fn style(mut self, style: impl ComboBoxStyle) -> Self {
434        self.style_override = Some(Rc::new(style));
435        self
436    }
437
438    /// Override the selected-value text style (font, size, weight).
439    /// Accepts a `TextStyleRole`, a `TextStyle`, or a `Signal` of either.
440    /// Default (unset) is `TextStyleRole::Body`.
441    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
442        self.label_style = Some(style.into());
443        self
444    }
445
446    /// Override the selected-value text color. Accepts `Color`, a role, or
447    /// a `Signal` of either. Default (unset) is enabled-derived
448    /// (`Primary` / `Disabled`); setting this replaces that cascade.
449    pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
450        self.text_role_override = Some(color.into());
451        self
452    }
453
454    /// Attach a plain tooltip that appears after a hover delay. The
455    /// tooltip is anchored to the trigger only — with the framework's
456    /// overlay-boundary gate it does not re-trigger while the pointer
457    /// is over the open dropdown's option rows.
458    ///
459    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip) /
460    /// [`rich_tooltip_content`](Self::rich_tooltip_content) /
461    /// [`composite_tooltip`](Self::composite_tooltip) — last call wins.
462    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
463        self.tooltip_text = Some(text.into());
464        self.rich_tooltip_source = None;
465        self.composite_tooltip_content = None;
466        self
467    }
468
469    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
470    /// The `key` is looked up via
471    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) at build
472    /// time; the resolved body supports inline markup, a shortcut chip,
473    /// and a "more" disclosure. Overrides any previously set tooltip.
474    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
475        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
476        self.tooltip_text = None;
477        self.composite_tooltip_content = None;
478        self
479    }
480
481    /// Attach a rich tooltip driven by inline
482    /// [`TooltipContent`](crate::tooltip::TooltipContent) — for one-off
483    /// tooltips that aren't worth registering centrally. Overrides any
484    /// previously set tooltip.
485    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
486        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
487        self.tooltip_text = None;
488        self.composite_tooltip_content = None;
489        self
490    }
491
492    /// Attach a composite tooltip — third tier, hosting an arbitrary
493    /// widget tree (tabbed sections, charts, conditional rows). Promotes
494    /// to a focusable `Role::Dialog` after the standard dwell. Overrides
495    /// any plain or rich tooltip previously set.
496    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
497        self.composite_tooltip_content = Some(Box::new(content));
498        self.tooltip_text = None;
499        self.rich_tooltip_source = None;
500        self
501    }
502
503    /// Boxed variant of [`composite_tooltip`](Self::composite_tooltip).
504    /// Used by wrapper widgets (e.g. `ThemeSwitcher`) that store a
505    /// `Box<dyn Widget>` and forward it through.
506    pub(crate) fn composite_tooltip_boxed(mut self, content: Box<dyn Widget>) -> Self {
507        self.composite_tooltip_content = Some(content);
508        self.tooltip_text = None;
509        self.rich_tooltip_source = None;
510        self
511    }
512}
513
514/// Searchable-mode builders. The search field is a `TextInput`, which
515/// shares the `RichTextEditor` engine and therefore the `teksilo-text`
516/// dependency.
517impl<T: Clone + PartialEq + 'static> ComboBox<T> {
518    /// Show a search field at the top of the dropdown panel and filter
519    /// the list live against the user's query. When `true`, items are
520    /// matched by the closure passed to [`filter`](Self::filter), or —
521    /// if no filter is set — by a case-insensitive substring match on
522    /// the [`item_label`](Self::item_label).
523    ///
524    /// The search input becomes a child of the dropdown panel only,
525    /// not of the trigger: the closed combo box looks identical
526    /// whether searchable or not.
527    ///
528    /// The query signal is created internally. Use
529    /// [`search_query`](Self::search_query) to supply your own if you
530    /// want to observe or drive the query externally.
531    pub fn searchable(mut self, enabled: bool) -> Self {
532        self.searchable = enabled;
533        if !enabled {
534            self.search_query = None;
535        }
536        self
537    }
538
539    /// Bind the search field to an external `Signal<String>`. Implies
540    /// [`searchable(true)`](Self::searchable). Useful for observing or
541    /// programmatically setting the query from outside the widget
542    /// (e.g. a "Clear" button, persistence across sessions).
543    pub fn search_query(mut self, query: Signal<String>) -> Self {
544        self.search_query = Some(query);
545        self.searchable = true;
546        self
547    }
548
549    /// Custom match predicate for searchable mode. Called on every
550    /// visible-item pass with the current query string (as typed, not
551    /// normalized) and a reference to the item; return `true` to keep
552    /// the item in the filtered list. Only consulted when
553    /// [`searchable`](Self::searchable) is `true`. Ignored otherwise.
554    pub fn filter(mut self, f: impl Fn(&str, &T) -> bool + 'static) -> Self {
555        self.filter = Some(Rc::new(f));
556        self
557    }
558}
559
560impl<T: Clone + PartialEq + 'static> std::fmt::Debug for ComboBox<T> {
561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562        f.debug_struct("ComboBox")
563            .field("items", &self.source.len())
564            .field("enabled", &self.enabled.get())
565            .finish()
566    }
567}
568
569impl<T: Clone + PartialEq + 'static> Widget for ComboBox<T> {
570    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
571        let self_id = ctx.self_id();
572        // Forward the enabled state to the arena; see IconButton.
573        ctx.enabled_when(self_id, self.enabled.clone());
574        let effective_enabled = ctx.effective_enabled_signal(self_id);
575
576        // Refresh the four interaction signals every build. The three
577        // non-disabled ones start in their resting state; `is_disabled`
578        // now mirrors the arena's effective enabled-state reactively
579        // (replaced the build-time snapshot — see IconButton). We
580        // wire `effective_enabled.not()` into `self.is_disabled` so
581        // existing observers keep working without rewiring.
582        self.is_open.set(false);
583        self.is_hovered.set(false);
584        self.is_focused.set(false);
585        // Drive `self.is_disabled` from the arena's effective_enabled.
586        // Replace with a derived signal — but `self.is_disabled` is
587        // owned by the widget and may have observers, so push the
588        // current value and register an effect to keep it in sync.
589        self.is_disabled.set(!effective_enabled.get());
590        {
591            let is_disabled = self.is_disabled.clone();
592            ctx.effect(&effective_enabled, move |on| {
593                let want = !*on;
594                if is_disabled.get() != want {
595                    is_disabled.set(want);
596                }
597            });
598        }
599
600        // Observe model changes so the dropdown panel rebuilds when the
601        // backing data mutates, and so selection is cleared when the
602        // currently-selected value disappears from the model.
603        //
604        // Trigger-level rebuild is NOT required: the trigger's label binds
605        // via `self.selected.map(...)`, which re-fires whenever `selected`
606        // itself changes. The observer already clears `selected` when the
607        // value vanishes, so the derived label updates automatically.
608        let panel_version = ctx.signal(0_u64);
609        let pv = panel_version.clone();
610        let observe_handle = (self.source.observe)(Box::new({
611            let source = self.source.clone();
612            let selected = self.selected.clone();
613            let hint = self.selected_index_hint.clone();
614            move |_change: &DataChange| {
615                // If the currently-selected value is no longer present
616                // in the model, clear selection. Works for Reset,
617                // ItemsRemoved, and ItemUpdated. The hint is also
618                // invalidated unconditionally: any mutation may have
619                // shifted the index of the selected value.
620                hint.set(None);
621                if let Some(cur) = selected.get()
622                    && resolve_index(&source, &cur, &hint).is_none()
623                {
624                    selected.set(None);
625                }
626                pv.set(pv.get().wrapping_add(1));
627            }
628        }));
629        ctx.own_handle(observe_handle);
630
631        // Derive label text from selected signal + source + locale.
632        // Uses `zip` so the label re-computes on both selection change
633        // and locale switch, enabling live re-translation.
634        let source_for_label = self.source.clone();
635        let item_label_for_trigger = self.item_label.clone();
636        let placeholder = self.placeholder.clone();
637        let hint_for_label = self.selected_index_hint.clone();
638        let locale_signal = ctx.locale_signal();
639        let label_text = self
640            .selected
641            .zip(&locale_signal)
642            .map(move |(sel, _)| match sel {
643                Some(v) => match resolve_index(&source_for_label, v, &hint_for_label) {
644                    Some(_) => (item_label_for_trigger)(v).resolve_now(),
645                    None => placeholder.resolve_now(),
646                },
647                None => placeholder.resolve_now(),
648            });
649
650        // Label colour follows the disabled signal — the chrome style
651        // owns bg / border / focus ring; the widget owns its label.
652        let text_role: teksilo_core::color_prop::ColorProp = match &self.text_role_override {
653            Some(c) => c.clone(),
654            None => self
655                .is_disabled
656                .map(|d| {
657                    if *d {
658                        TextRole::Disabled
659                    } else {
660                        TextRole::Primary
661                    }
662                })
663                .into(),
664        };
665
666        // Build the selected-value subtree the style will host. Either the
667        // default reactive text label, or — when `render_selected` is set —
668        // a custom trigger view (`SelectedContent`) rebuilt on each
669        // selection change. Both are excluded from the accessibility tree:
670        // the combo box's own `accessibility(builder)` already announces the
671        // selected value via `set_value`, so an exposed inner text node
672        // would double-announce.
673        let label_id = if let Some(render) = self.render_selected.clone() {
674            ctx.add(
675                SelectedContent {
676                    selected: self.selected.clone(),
677                    render,
678                    placeholder: self.placeholder.clone(),
679                    placeholder_style: self.label_style.clone(),
680                    text_role: text_role.clone(),
681                    child: None,
682                }
683                .access_exclude_subtree(),
684            )
685        } else {
686            let mut label = TextWidget::new(lit!(""))
687                .text(label_text)
688                .color(text_role)
689                .single_line()
690                .a11y_hidden();
691            label = match &self.label_style {
692                Some(style) => label.style(style.clone()),
693                None => label.style(TextStyleRole::Body),
694            };
695            ctx.add(label)
696        };
697
698        // Resolve the active style: per-call override > theme slot >
699        // built-in `RecipeComboBoxStyle` default. The style produces
700        // the entire trigger chrome (bg + border + padding + divider +
701        // chevron + min-height) around our `selected_label`.
702        let style: SharedComboBoxStyle = self
703            .style_override
704            .clone()
705            .or_else(|| ctx.theme().style_slots.combo_box.clone())
706            .unwrap_or_else(|| {
707                Rc::new(crate::styles::RecipeComboBoxStyle::for_tokens(
708                    &ctx.theme().input,
709                ))
710            });
711
712        let cfg = ComboBoxStyleConfig {
713            selected_label: label_id,
714            is_open: self.is_open.clone(),
715            is_hovered: self.is_hovered.clone(),
716            // `:focus-visible`: keyboard-only focus ring (gate raw focus on
717            // the input-modality signal).
718            is_focused: self.is_focused.and(&ctx.focus_visible()),
719            is_disabled: self.is_disabled.clone(),
720            variant: self.variant,
721        };
722        let root_id = style.make_body(&cfg, ctx);
723        self.root_child_id = Some(root_id);
724
725        // Attach a tooltip if configured. The three setters
726        // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are mutually
727        // exclusive — every setter clears the other two, so exactly one
728        // branch runs. The anchor is the trigger chrome (`root_id`); the
729        // framework's overlay-boundary gate keeps the tooltip from
730        // leaking onto the open dropdown's rows.
731        if let Some(content) = self.composite_tooltip_content.take() {
732            let delay = ctx.theme().motion.tooltip_delay_heavy;
733            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
734        } else if let Some(source) = self.rich_tooltip_source.clone() {
735            let delay = ctx.theme().motion.tooltip_delay;
736            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
737        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
738            let delay = ctx.theme().motion.tooltip_delay;
739            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
740        }
741
742        // Pre-create the dropdown panel (dormant until opened). On
743        // rebuild, first tear down the previous panel subtree — it was
744        // inserted as an arena root via `ctx.add(..)` + `set_dormant`,
745        // so the framework's rebuild path (which only destroys this
746        // widget's direct arena children) would otherwise leave it
747        // behind as an orphan on every model mutation.
748        if let Some(old_id) = self.dropdown_content_id.take() {
749            ctx.destroy_subtree(old_id);
750        }
751
752        // Searchable mode: allocate the query signal lazily so toggling
753        // `searchable(true)` → `false` between rebuilds doesn't keep a
754        // stale signal alive, while `true` → `true` preserves the
755        // in-progress query across model mutations.
756        let search_query = if self.searchable {
757            let existing = self.search_query.clone();
758            let q = existing.unwrap_or_else(|| Signal::new(String::new()));
759            self.search_query = Some(q.clone());
760            Some(q)
761        } else {
762            self.search_query = None;
763            None
764        };
765
766        // Shared slot carrying the search `TextInput`'s widget id —
767        // populated by the panel during its own `build`. The open path
768        // below does *not* read it: it asks for focus by panel id
769        // instead (see there for why the slot is empty on the very
770        // first open).
771        let search_input_slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
772        let dropdown_panel = DropdownPanel {
773            source: self.source.clone(),
774            selected: self.selected.clone(),
775            item_label: self.item_label.clone(),
776            render_item: self.render_item.clone(),
777            on_select: self.on_select.clone(),
778            max_visible_items: self.max_visible_items,
779            version: panel_version,
780            search_query,
781            filter: self.filter.clone(),
782            search_input_slot: search_input_slot.clone(),
783            visible_count_slot: Rc::new(Cell::new(0)),
784            root_child_id: None,
785        };
786        // Built the first time the combo is opened, not here. A closed combo
787        // box used to build its whole panel — every option row — on every
788        // rebuild of its owner; in a table cell that is once per row, per
789        // rebuild. See `teksilo_core::deferred_subtree::DeferredSubtree`.
790        let dropdown_id = ctx.add_deferred(self.is_open.clone(), dropdown_panel);
791        self.dropdown_content_id = Some(dropdown_id);
792        ctx.set_dormant(dropdown_id);
793        // Make `is_open` the single source of truth for the panel's
794        // activation. The panel is reported by `children()` (for hit-test /
795        // a11y / teardown) but is an orphan arena root opened as an overlay;
796        // without this binding a framework re-activation (e.g. the combo
797        // reappearing from a `visible_when` collapse inside a `Toolbar`) can
798        // leave the panel active while closed, painting ghost option rows. The
799        // per-pass visibility reconciliation dormants it again whenever the
800        // combo is not open.
801        ctx.visible_when(dropdown_id, self.is_open.clone());
802
803        // --- Handlers ---
804        let self_id = ctx.self_id();
805        let is_open_h = self.is_open.clone();
806        let is_hovered_h = self.is_hovered.clone();
807        let is_focused_h = self.is_focused.clone();
808
809        // Shared dismiss callback — invoked by the overlay manager
810        // whenever the dropdown is dismissed, regardless of path
811        // (our own Enter/Escape handlers, framework-level
812        // EscapeOrClickOutside, pointer-leave, cascade). Flips
813        // `is_open` back to false so `accessibility(builder)` stays
814        // truthful about the popup state.
815        let dismiss_callback: OverlayDismissCallback = {
816            let is_open = self.is_open.clone();
817            Rc::new(move |_, _| {
818                if is_open.get() {
819                    is_open.set(false);
820                }
821            })
822        };
823
824        // Helper to open the overlay — used by tap and several key handlers.
825        let open_overlay = {
826            let is_open = self.is_open.clone();
827            let dismiss_callback = dismiss_callback.clone();
828            let searchable = self.searchable;
829            Rc::new(move |ctx: &mut EventContext| {
830                is_open.set(true);
831                // Build the panel if this is its first open, before the overlay
832                // below is measured against it and before focus moves into it.
833                ctx.materialize_now(dropdown_id);
834                ctx.activate(dropdown_id);
835                ctx.show_overlay(OverlayRequest {
836                    content_id: dropdown_id,
837                    anchor: self_id,
838                    placement: OverlayPlacement::BelowPreferred,
839                    dismiss: DismissBehavior::EscapeOrClickOutside,
840                    layer: OverlayLayer::InTree,
841                    parent_overlay: None,
842                    on_dismiss: Some(dismiss_callback.clone()),
843                    fade_duration: None,
844                });
845                // Searchable mode: land focus in the search field so
846                // the user can start typing immediately after opening.
847                //
848                // Asked for by *panel* id rather than by reading the slot the
849                // panel fills in during its build: the panel may not have been
850                // built yet when this handler runs (see `materialize_now`
851                // above), so the slot would be empty on the very first open.
852                // `request_focus` walks to the first focusable descendant, and
853                // in a searchable panel that is the search field — and focus
854                // requests are applied after the tree mutations that build it.
855                // Gated on `searchable` so a plain dropdown still moves focus
856                // nowhere, exactly as an empty slot did.
857                if searchable {
858                    ctx.request_focus(dropdown_id);
859                }
860            })
861        };
862
863        // Framework gates events on `arena.is_enabled` — no per-
864        // handler enabled snapshot guards anymore.
865        let handler_set = HandlerSet::new()
866            .on_tap({
867                let open_overlay = open_overlay.clone();
868                move |_pos, ctx: &mut EventContext| {
869                    open_overlay(ctx);
870                }
871            })
872            .on_hover({
873                let is_open = is_open_h.clone();
874                let is_hovered = is_hovered_h.clone();
875                move |entered: bool, _ctx: &mut EventContext| {
876                    // Don't churn the hovered signal while the dropdown
877                    // is open — the bg stays in its open colour until
878                    // the overlay dismisses.
879                    if is_open.get() {
880                        return;
881                    }
882                    is_hovered.set(entered);
883                }
884            })
885            .on_key({
886                let is_open = self.is_open.clone();
887                let selected = self.selected.clone();
888                let source = self.source.clone();
889                let item_label_for_keys = self.item_label.clone();
890                let hint = self.selected_index_hint.clone();
891                let open_overlay = open_overlay.clone();
892                // PageUp/PageDown step by one visible page (clamped to 1
893                // so a `max_visible_items(1)` combo still moves).
894                let page_size = self.max_visible_items.max(1);
895                // Type-ahead buffer: (prefix, last_keystroke_time)
896                let typeahead: Rc<RefCell<(String, Instant)>> =
897                    Rc::new(RefCell::new((String::new(), Instant::now())));
898                let type_ahead_timeout = self.type_ahead_timeout;
899                // Helper: set selection to the item at `index`, update the
900                // cached hint, and fire `on_select` (with the live
901                // `EventContext`) in one shot — mirroring the dropdown-row
902                // tap path so keyboard and mouse commits are equivalent.
903                let on_select_for_keys = self.on_select.clone();
904                let pick_at = {
905                    let source = source.clone();
906                    let selected = selected.clone();
907                    let hint = hint.clone();
908                    Rc::new(move |index: usize, ctx: &mut EventContext| {
909                        if let Some(v) = source.get(index) {
910                            hint.set(Some(index));
911                            selected.set(Some(v.clone()));
912                            if let Some(cb) = &on_select_for_keys {
913                                cb(&v, ctx);
914                            }
915                        }
916                    })
917                };
918                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
919                    let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
920                        return EventResponse::Ignored;
921                    };
922
923                    // The platform drop-down chords — `Alt+ArrowDown` opens
924                    // the list without moving the selection, `Alt+ArrowUp`
925                    // closes it, `F4` toggles. One table, shared with
926                    // `PopoverWidget` and `DateEdit`, so the three cannot
927                    // drift. Claimed above the rejection below, which turns
928                    // every other accelerator chord away, and below the
929                    // shortcut pipeline, so an app that binds `F4` itself keeps
930                    // it.
931                    match range_nav::disclosure_chord(*key, *modifiers) {
932                        Some(range_nav::DisclosureChord::Open) => {
933                            if !is_open.get() {
934                                open_overlay(ctx);
935                            }
936                            return EventResponse::Handled;
937                        }
938                        Some(range_nav::DisclosureChord::Close) => {
939                            if is_open.get() {
940                                is_open.set(false);
941                                ctx.dismiss_all_except_hosts();
942                                return EventResponse::Handled;
943                            }
944                            return EventResponse::Ignored;
945                        }
946                        Some(range_nav::DisclosureChord::Toggle) => {
947                            if is_open.get() {
948                                is_open.set(false);
949                                ctx.dismiss_all_except_hosts();
950                            } else {
951                                open_overlay(ctx);
952                            }
953                            return EventResponse::Handled;
954                        }
955                        None => {}
956                    }
957
958                    // Everything below is a chord that typed a character —
959                    // bare, `Shift`-only, or `AltGr`, which is `Ctrl+Alt` on
960                    // every platform that has it and is how a German keyboard
961                    // types `@`. `Ctrl` alone and `Alt` alone are accelerators
962                    // and fall through; see `range_nav::is_text_entry_chord`
963                    // for why this is not simply the negation of the
964                    // accelerator test, and why neither spells
965                    // `Modifiers::command()`.
966                    //
967                    // Without this, `Ctrl+C` over a focused combo appended 'c'
968                    // to the type-ahead prefix and jumped the selection to the
969                    // first "C..." item — mutating the user's value — then
970                    // returned `Handled`, so no ancestor ever saw the chord.
971                    // Registered `Shortcut`s resolve before any of this, so an
972                    // app-bound chord was never at risk; an *unbound* one was.
973                    if !range_nav::is_text_entry_chord(*modifiers) {
974                        return EventResponse::Ignored;
975                    }
976
977                    // …but only *type-ahead* runs for `AltGr`. `AltGr` is
978                    // `Ctrl+Alt`, which is still an accelerator to everything
979                    // that is not a character: `disclosure_chord` above
980                    // deliberately declines `Ctrl+Alt+↓` so the application
981                    // gets it, and it would be undone here if the arrow arm
982                    // below then opened the list, moved the selection and
983                    // reported the chord handled. The navigation keys are
984                    // therefore the unmodified (or `Shift`-ed) forms only.
985                    let nav = !range_nav::is_accelerator_chord(*modifiers);
986
987                    match event {
988                        WidgetEvent::KeyDown {
989                            key: Key::Enter | Key::Space,
990                            ..
991                        } if nav => {
992                            if is_open.get() {
993                                is_open.set(false);
994                                ctx.dismiss_all_except_hosts();
995                            } else {
996                                open_overlay(ctx);
997                            }
998                            EventResponse::Handled
999                        }
1000                        WidgetEvent::KeyDown {
1001                            key: Key::Escape, ..
1002                        } if nav => {
1003                            if is_open.get() {
1004                                is_open.set(false);
1005                                ctx.dismiss_all_except_hosts();
1006                                EventResponse::Handled
1007                            } else {
1008                                EventResponse::Ignored
1009                            }
1010                        }
1011                        // Tab is deliberately *not* handled here. It used to be:
1012                        // the arm consumed the keystroke, closed the dropdown
1013                        // and left focus sitting on the trigger, so a second Tab
1014                        // was needed to actually move on. The framework now
1015                        // dismisses any non-modal overlay the keyboard walks out
1016                        // of, which covers this widget too — so letting Tab fall
1017                        // through to the ordinary focus cycle both closes the
1018                        // popup and advances in one press, the way a combobox is
1019                        // supposed to behave as a normal tab stop.
1020                        WidgetEvent::KeyDown {
1021                            key: Key::ArrowDown,
1022                            ..
1023                        } if nav => {
1024                            if !is_open.get() {
1025                                open_overlay(ctx);
1026                            }
1027                            let n = source.len();
1028                            if n == 0 {
1029                                return EventResponse::Handled;
1030                            }
1031                            // Treat "no selection" as an implicit cursor at
1032                            // index 0 — ArrowDown advances to index 1 from
1033                            // nothing (matching the framework convention
1034                            // across widgets that keyboard-navigate lists).
1035                            let current_idx = selected
1036                                .get()
1037                                .as_ref()
1038                                .and_then(|v| resolve_index(&source, v, &hint))
1039                                .unwrap_or(0);
1040                            // Stop at the last item; do not wrap. The page
1041                            // keys below already clamped, so the widget
1042                            // disagreed with itself inside one handler — and a
1043                            // combo box is a *value*: one keypress too many
1044                            // must not teleport a setting from "Never" to
1045                            // "Always". Win32's combo box, `QComboBox`, GTK,
1046                            // the ARIA listbox pattern and Teksilo's own
1047                            // `ListView` all stop at the ends; menus wrap
1048                            // because a menu is a list of commands, not a
1049                            // value.
1050                            let target = (current_idx + 1).min(n - 1);
1051                            pick_at(target, ctx);
1052                            EventResponse::Handled
1053                        }
1054                        WidgetEvent::KeyDown {
1055                            key: Key::ArrowUp, ..
1056                        } if nav => {
1057                            if !is_open.get() {
1058                                open_overlay(ctx);
1059                            }
1060                            let n = source.len();
1061                            if n == 0 {
1062                                return EventResponse::Handled;
1063                            }
1064                            let current_idx = selected
1065                                .get()
1066                                .as_ref()
1067                                .and_then(|v| resolve_index(&source, v, &hint))
1068                                .unwrap_or(0);
1069                            let target = current_idx.saturating_sub(1);
1070                            pick_at(target, ctx);
1071                            EventResponse::Handled
1072                        }
1073                        WidgetEvent::KeyDown { key: Key::Home, .. } if nav => {
1074                            if source.len() == 0 {
1075                                return EventResponse::Handled;
1076                            }
1077                            pick_at(0, ctx);
1078                            EventResponse::Handled
1079                        }
1080                        WidgetEvent::KeyDown { key: Key::End, .. } if nav => {
1081                            let n = source.len();
1082                            if n == 0 {
1083                                return EventResponse::Handled;
1084                            }
1085                            pick_at(n - 1, ctx);
1086                            EventResponse::Handled
1087                        }
1088                        // PageDown / PageUp — advance or retreat selection
1089                        // by one page, where a page is `max_visible_items`
1090                        // rows. Mirrors the standard combo-box keyboard
1091                        // convention and also gets the visible range to
1092                        // follow via `register_scroll_into_view`.
1093                        WidgetEvent::KeyDown {
1094                            key: Key::PageDown, ..
1095                        } if nav => {
1096                            let n = source.len();
1097                            if n == 0 {
1098                                return EventResponse::Handled;
1099                            }
1100                            if !is_open.get() {
1101                                open_overlay(ctx);
1102                            }
1103                            let current_idx = selected
1104                                .get()
1105                                .as_ref()
1106                                .and_then(|v| resolve_index(&source, v, &hint))
1107                                .unwrap_or(0);
1108                            let target = current_idx.saturating_add(page_size).min(n - 1);
1109                            pick_at(target, ctx);
1110                            EventResponse::Handled
1111                        }
1112                        WidgetEvent::KeyDown {
1113                            key: Key::PageUp, ..
1114                        } if nav => {
1115                            let n = source.len();
1116                            if n == 0 {
1117                                return EventResponse::Handled;
1118                            }
1119                            if !is_open.get() {
1120                                open_overlay(ctx);
1121                            }
1122                            let current_idx = selected
1123                                .get()
1124                                .as_ref()
1125                                .and_then(|v| resolve_index(&source, v, &hint))
1126                                .unwrap_or(0);
1127                            let target = current_idx.saturating_sub(page_size);
1128                            pick_at(target, ctx);
1129                            EventResponse::Handled
1130                        }
1131                        // Type-ahead: letter/character keys jump to matching item.
1132                        WidgetEvent::KeyDown { key, .. } if key.to_char().is_some() => {
1133                            let ch = key.to_char().unwrap();
1134                            let mut ta = typeahead.borrow_mut();
1135                            let now = Instant::now();
1136                            // Reset the prefix once keystrokes fall outside the
1137                            // type-ahead window.
1138                            if now.duration_since(ta.1) > type_ahead_timeout {
1139                                ta.0.clear();
1140                            }
1141                            // Full Unicode lowercasing so accented input (e.g.
1142                            // 'É') matches accented labels — `to_ascii_lowercase`
1143                            // is a no-op on non-ASCII and would never match.
1144                            ta.0.extend(ch.to_lowercase());
1145                            ta.1 = now;
1146                            let prefix = ta.0.clone();
1147                            drop(ta);
1148
1149                            // Find first item whose label starts with the prefix
1150                            // (case-insensitive).
1151                            let n = source.len();
1152                            for i in 0..n {
1153                                if let Some(v) = source.get(i) {
1154                                    let label = (item_label_for_keys)(&v).resolve_now();
1155                                    if label.to_lowercase().starts_with(&prefix) {
1156                                        pick_at(i, ctx);
1157                                        break;
1158                                    }
1159                                }
1160                            }
1161                            EventResponse::Handled
1162                        }
1163                        _ => EventResponse::Ignored,
1164                    }
1165                }
1166            })
1167            .on_focus(move |gained: bool, _ctx: &mut EventContext| {
1168                is_focused_h.set(gained);
1169            })
1170            // `accessibility` advertises `Action::Click`; the dispatcher
1171            // routes an AT / automation click here rather than
1172            // synthesizing a pointer tap, so the dropdown must be opened
1173            // explicitly. Every platform adapter funnels activation
1174            // through `Click` (AT-SPI `DoAction(0)`, Windows Invoke,
1175            // macOS `accessibilityPerformPress`) — none sends
1176            // `Expand`/`Collapse` — so this is the only AT open path.
1177            .on_access_action({
1178                let open_overlay = open_overlay.clone();
1179                move |action, ctx: &mut EventContext| {
1180                    if action == teksilo_core::accesskit::Action::Click {
1181                        open_overlay(ctx);
1182                        EventResponse::Handled
1183                    } else {
1184                        EventResponse::Ignored
1185                    }
1186                }
1187            })
1188            // Focus walker skips disabled subtrees on its own.
1189            .focusable(true)
1190            .cursor(CursorIcon::Pointer);
1191
1192        ctx.apply_self_handlers(handler_set);
1193
1194        // Return BOTH the trigger root AND the dormant dropdown as
1195        // children so the framework links `dropdown_id` under this
1196        // widget in the arena instead of leaving it an orphan root.
1197        // Hit-test walks all arena roots; an orphan dormant subtree
1198        // can leak into hit-tests at fallback bounds and intercept
1199        // clicks meant for siblings. See popover_widget.rs for the
1200        // same pattern.
1201        vec![root_id, dropdown_id]
1202    }
1203
1204    fn layout_response(
1205        &self,
1206        proposal: SizeProposal,
1207        ctx: &LayoutContext,
1208    ) -> teksilo_core::widget::LayoutResponse {
1209        let min_height = crate::styles::recipe_combo_box_style::COMBO_BOX_HEIGHT;
1210        const MIN_WIDTH: f32 = 120.0;
1211        // Rigid: size to content (clamped to the combo's minimum), no shrink
1212        // (see Button's note). Wrap in `Shrinkable` to opt into compression.
1213        match self.root_child_id {
1214            Some(id) => {
1215                let child_size = ctx
1216                    .child_size(id, proposal)
1217                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1218                Size::new(
1219                    child_size.width.max(MIN_WIDTH),
1220                    child_size.height.max(min_height),
1221                )
1222            }
1223            None => proposal.resolve(MIN_WIDTH, min_height),
1224        }
1225        .into()
1226    }
1227
1228    fn place_children(
1229        &self,
1230        bounds: Rect,
1231        _proposal: SizeProposal,
1232        children: &mut [WidgetPlacement],
1233        _ctx: &LayoutContext,
1234    ) {
1235        // The trigger fills our bounds; the dropdown's bounds are
1236        // owned by the overlay manager when shown (`position_overlays`),
1237        // so we zero-size it here.
1238        for child in children.iter_mut() {
1239            if Some(child.id) == self.dropdown_content_id {
1240                child.size = teksilo_canvas::Size::ZERO;
1241                continue;
1242            }
1243            child.origin = bounds.origin();
1244            child.size = bounds.size();
1245        }
1246    }
1247
1248    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1249        builder.set_role(teksilo_core::accesskit::Role::ComboBox);
1250        builder.set_has_popup(teksilo_core::accesskit::HasPopup::Listbox);
1251
1252        if let Some(name) = self.label.as_ref() {
1253            builder.set_name(name.resolve_now());
1254        }
1255
1256        // A11y gap #3: use `placeholder` when nothing is selected, `value`
1257        // when something is. The two are distinct ARIA properties; screen
1258        // readers announce placeholders as hints rather than current values.
1259        match self.selected.get() {
1260            Some(v) => {
1261                let label = (self.item_label)(&v).resolve_now();
1262                if !label.is_empty() {
1263                    builder.set_value(label);
1264                }
1265            }
1266            None => {
1267                let ph = self.placeholder.resolve_now();
1268                if !ph.is_empty() {
1269                    builder.set_placeholder(ph);
1270                }
1271            }
1272        }
1273
1274        builder.set_expanded(self.is_open.get());
1275
1276        // Only set aria-controls when the popup is open — the listbox node is
1277        // absent from the tree when closed, and pointing at a missing node
1278        // causes AT crashes (VoiceOver unwrap in linked_ui_elements).
1279        if self.is_open.get()
1280            && let Some(popup_id) = self.dropdown_content_id
1281        {
1282            builder.push_controlled(widget_id_to_node_id(popup_id));
1283        }
1284
1285        // ARIA combobox pattern: when the popup is a filtered list, mark
1286        // `aria-autocomplete="list"` so assistive tech announces the
1287        // filter behavior. Only applied in searchable mode.
1288        if self.searchable {
1289            builder.set_auto_complete(teksilo_core::accesskit::AutoComplete::List);
1290        }
1291
1292        // Always advertise actions — framework gates them at dispatch
1293        // via `arena.is_enabled`, and the a11y walker handles
1294        // `set_disabled` from the same arena state.
1295        builder.add_action(teksilo_core::accesskit::Action::Click);
1296        builder.add_action(teksilo_core::accesskit::Action::Focus);
1297    }
1298
1299    fn children(&self) -> Vec<WidgetId> {
1300        let mut out = Vec::new();
1301        if let Some(id) = self.root_child_id {
1302            out.push(id);
1303        }
1304        if let Some(id) = self.dropdown_content_id {
1305            out.push(id);
1306        }
1307        out
1308    }
1309}
1310
1311/// Trigger-content wrapper used when the caller supplies
1312/// [`ComboBox::render_selected`]. Rebuilds its single child whenever the
1313/// selection (or locale) changes, so the custom selected-value view tracks
1314/// the selection without rebuilding the whole ComboBox. Laid out to fill the
1315/// slot the [`ComboBoxStyle`] gives it, exactly like the default text label.
1316struct SelectedContent<T: Clone + PartialEq + 'static> {
1317    selected: Signal<Option<T>>,
1318    render: Rc<dyn Fn(&T) -> Box<dyn Widget>>,
1319    placeholder: LocalizedString,
1320    placeholder_style: Option<teksilo_core::color_prop::TextStyleProp>,
1321    text_role: teksilo_core::color_prop::ColorProp,
1322    child: Option<WidgetId>,
1323}
1324
1325impl<T: Clone + PartialEq + 'static> std::fmt::Debug for SelectedContent<T> {
1326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1327        f.debug_struct("SelectedContent").finish_non_exhaustive()
1328    }
1329}
1330
1331impl<T: Clone + PartialEq + 'static> Widget for SelectedContent<T> {
1332    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1333        use teksilo_core::binding::BindingLevel;
1334        // Rebuild on selection change (new value → new custom view) and on
1335        // locale change (so the `None`-state placeholder re-translates).
1336        self.selected
1337            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1338        ctx.locale_signal()
1339            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1340
1341        let child = match self.selected.get() {
1342            Some(v) => ctx.add_boxed((self.render)(&v)),
1343            None => {
1344                let mut ph = TextWidget::new(self.placeholder.clone())
1345                    .color(self.text_role.clone())
1346                    .single_line();
1347                ph = match &self.placeholder_style {
1348                    Some(style) => ph.style(style.clone()),
1349                    None => ph.style(TextStyleRole::Body),
1350                };
1351                ctx.add(ph)
1352            }
1353        };
1354        self.child = Some(child);
1355        vec![child]
1356    }
1357
1358    fn layout_response(
1359        &self,
1360        proposal: SizeProposal,
1361        ctx: &LayoutContext,
1362    ) -> teksilo_core::widget::LayoutResponse {
1363        self.child
1364            .and_then(|id| ctx.child_size(id, proposal))
1365            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1366            .into()
1367    }
1368
1369    fn place_children(
1370        &self,
1371        bounds: Rect,
1372        _proposal: SizeProposal,
1373        children: &mut [WidgetPlacement],
1374        _ctx: &LayoutContext,
1375    ) {
1376        for child in children.iter_mut() {
1377            child.origin = bounds.origin();
1378            child.size = bounds.size();
1379        }
1380    }
1381
1382    fn children(&self) -> Vec<WidgetId> {
1383        self.child.into_iter().collect()
1384    }
1385}