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