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            root_child_id: None,
746        };
747        // Built the first time the combo is opened, not here. A closed combo
748        // box used to build its whole panel — every option row — on every
749        // rebuild of its owner; in a table cell that is once per row, per
750        // rebuild. See `teksilo_core::deferred_subtree::DeferredSubtree`.
751        let dropdown_id = ctx.add_deferred(self.is_open.clone(), dropdown_panel);
752        self.dropdown_content_id = Some(dropdown_id);
753        ctx.set_dormant(dropdown_id);
754        // Make `is_open` the single source of truth for the panel's
755        // activation. The panel is reported by `children()` (for hit-test /
756        // a11y / teardown) but is an orphan arena root opened as an overlay;
757        // without this binding a framework re-activation (e.g. the combo
758        // reappearing from a `visible_when` collapse inside a `Toolbar`) can
759        // leave the panel active while closed, painting ghost option rows. The
760        // per-pass visibility reconciliation dormants it again whenever the
761        // combo is not open.
762        ctx.visible_when(dropdown_id, self.is_open.clone());
763
764        // --- Handlers ---
765        let self_id = ctx.self_id();
766        let is_open_h = self.is_open.clone();
767        let is_hovered_h = self.is_hovered.clone();
768        let is_focused_h = self.is_focused.clone();
769
770        // Shared dismiss callback — invoked by the overlay manager
771        // whenever the dropdown is dismissed, regardless of path
772        // (our own Enter/Escape handlers, framework-level
773        // EscapeOrClickOutside, pointer-leave, cascade). Flips
774        // `is_open` back to false so `accessibility(builder)` stays
775        // truthful about the popup state.
776        let dismiss_callback: OverlayDismissCallback = {
777            let is_open = self.is_open.clone();
778            Rc::new(move || {
779                if is_open.get() {
780                    is_open.set(false);
781                }
782            })
783        };
784
785        // Helper to open the overlay — used by tap and several key handlers.
786        let open_overlay = {
787            let is_open = self.is_open.clone();
788            let dismiss_callback = dismiss_callback.clone();
789            let searchable = self.searchable;
790            Rc::new(move |ctx: &mut EventContext| {
791                is_open.set(true);
792                // Build the panel if this is its first open, before the overlay
793                // below is measured against it and before focus moves into it.
794                ctx.materialize_now(dropdown_id);
795                ctx.activate(dropdown_id);
796                ctx.show_overlay(OverlayRequest {
797                    content_id: dropdown_id,
798                    anchor: self_id,
799                    placement: OverlayPlacement::BelowPreferred,
800                    dismiss: DismissBehavior::EscapeOrClickOutside,
801                    layer: OverlayLayer::InTree,
802                    parent_overlay: None,
803                    on_dismiss: Some(dismiss_callback.clone()),
804                    fade_duration: None,
805                });
806                // Searchable mode: land focus in the search field so
807                // the user can start typing immediately after opening.
808                //
809                // Asked for by *panel* id rather than by reading the slot the
810                // panel fills in during its build: the panel may not have been
811                // built yet when this handler runs (see `materialize_now`
812                // above), so the slot would be empty on the very first open.
813                // `request_focus` walks to the first focusable descendant, and
814                // in a searchable panel that is the search field — and focus
815                // requests are applied after the tree mutations that build it.
816                // Gated on `searchable` so a plain dropdown still moves focus
817                // nowhere, exactly as an empty slot did.
818                if searchable {
819                    ctx.request_focus(dropdown_id);
820                }
821            })
822        };
823
824        // Framework gates events on `arena.is_enabled` — no per-
825        // handler enabled snapshot guards anymore.
826        let handler_set = HandlerSet::new()
827            .on_tap({
828                let open_overlay = open_overlay.clone();
829                move |_pos, ctx: &mut EventContext| {
830                    open_overlay(ctx);
831                }
832            })
833            .on_hover({
834                let is_open = is_open_h.clone();
835                let is_hovered = is_hovered_h.clone();
836                move |entered: bool, _ctx: &mut EventContext| {
837                    // Don't churn the hovered signal while the dropdown
838                    // is open — the bg stays in its open colour until
839                    // the overlay dismisses.
840                    if is_open.get() {
841                        return;
842                    }
843                    is_hovered.set(entered);
844                }
845            })
846            .on_key({
847                let is_open = self.is_open.clone();
848                let selected = self.selected.clone();
849                let source = self.source.clone();
850                let item_label_for_keys = self.item_label.clone();
851                let hint = self.selected_index_hint.clone();
852                let open_overlay = open_overlay.clone();
853                // PageUp/PageDown step by one visible page (clamped to 1
854                // so a `max_visible_items(1)` combo still moves).
855                let page_size = self.max_visible_items.max(1);
856                // Type-ahead buffer: (prefix, last_keystroke_time)
857                let typeahead: Rc<RefCell<(String, Instant)>> =
858                    Rc::new(RefCell::new((String::new(), Instant::now())));
859                let type_ahead_timeout = self.type_ahead_timeout;
860                // Helper: set selection to the item at `index`, update the
861                // cached hint, and fire `on_select` (with the live
862                // `EventContext`) in one shot — mirroring the dropdown-row
863                // tap path so keyboard and mouse commits are equivalent.
864                let on_select_for_keys = self.on_select.clone();
865                let pick_at = {
866                    let source = source.clone();
867                    let selected = selected.clone();
868                    let hint = hint.clone();
869                    Rc::new(move |index: usize, ctx: &mut EventContext| {
870                        if let Some(v) = source.get(index) {
871                            hint.set(Some(index));
872                            selected.set(Some(v.clone()));
873                            if let Some(cb) = &on_select_for_keys {
874                                cb(&v, ctx);
875                            }
876                        }
877                    })
878                };
879                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
880                    match event {
881                        WidgetEvent::KeyDown {
882                            key: Key::Enter | Key::Space,
883                            ..
884                        } => {
885                            if is_open.get() {
886                                is_open.set(false);
887                                ctx.dismiss_all_except_hosts();
888                            } else {
889                                open_overlay(ctx);
890                            }
891                            EventResponse::Handled
892                        }
893                        WidgetEvent::KeyDown {
894                            key: Key::Escape, ..
895                        } => {
896                            if is_open.get() {
897                                is_open.set(false);
898                                ctx.dismiss_all_except_hosts();
899                                EventResponse::Handled
900                            } else {
901                                EventResponse::Ignored
902                            }
903                        }
904                        // Tab is deliberately *not* handled here. It used to be:
905                        // the arm consumed the keystroke, closed the dropdown
906                        // and left focus sitting on the trigger, so a second Tab
907                        // was needed to actually move on. The framework now
908                        // dismisses any non-modal overlay the keyboard walks out
909                        // of, which covers this widget too — so letting Tab fall
910                        // through to the ordinary focus cycle both closes the
911                        // popup and advances in one press, the way a combobox is
912                        // supposed to behave as a normal tab stop.
913                        WidgetEvent::KeyDown {
914                            key: Key::ArrowDown,
915                            ..
916                        } => {
917                            if !is_open.get() {
918                                open_overlay(ctx);
919                            }
920                            let n = source.len();
921                            if n == 0 {
922                                return EventResponse::Handled;
923                            }
924                            // Treat "no selection" as an implicit cursor at
925                            // index 0 — ArrowDown advances to index 1 from
926                            // nothing (matching the framework convention
927                            // across widgets that keyboard-navigate lists).
928                            let current_idx = selected
929                                .get()
930                                .as_ref()
931                                .and_then(|v| resolve_index(&source, v, &hint))
932                                .unwrap_or(0);
933                            let target = (current_idx + 1) % n;
934                            pick_at(target, ctx);
935                            EventResponse::Handled
936                        }
937                        WidgetEvent::KeyDown {
938                            key: Key::ArrowUp, ..
939                        } => {
940                            if !is_open.get() {
941                                open_overlay(ctx);
942                            }
943                            let n = source.len();
944                            if n == 0 {
945                                return EventResponse::Handled;
946                            }
947                            let current_idx = selected
948                                .get()
949                                .as_ref()
950                                .and_then(|v| resolve_index(&source, v, &hint))
951                                .unwrap_or(0);
952                            let target = if current_idx == 0 {
953                                n - 1
954                            } else {
955                                current_idx - 1
956                            };
957                            pick_at(target, ctx);
958                            EventResponse::Handled
959                        }
960                        WidgetEvent::KeyDown { key: Key::Home, .. } => {
961                            if source.len() == 0 {
962                                return EventResponse::Handled;
963                            }
964                            pick_at(0, ctx);
965                            EventResponse::Handled
966                        }
967                        WidgetEvent::KeyDown { key: Key::End, .. } => {
968                            let n = source.len();
969                            if n == 0 {
970                                return EventResponse::Handled;
971                            }
972                            pick_at(n - 1, ctx);
973                            EventResponse::Handled
974                        }
975                        // PageDown / PageUp — advance or retreat selection
976                        // by one page, where a page is `max_visible_items`
977                        // rows. Mirrors the standard combo-box keyboard
978                        // convention and also gets the visible range to
979                        // follow via `register_scroll_into_view`.
980                        WidgetEvent::KeyDown {
981                            key: Key::PageDown, ..
982                        } => {
983                            let n = source.len();
984                            if n == 0 {
985                                return EventResponse::Handled;
986                            }
987                            if !is_open.get() {
988                                open_overlay(ctx);
989                            }
990                            let current_idx = selected
991                                .get()
992                                .as_ref()
993                                .and_then(|v| resolve_index(&source, v, &hint))
994                                .unwrap_or(0);
995                            let target = current_idx.saturating_add(page_size).min(n - 1);
996                            pick_at(target, ctx);
997                            EventResponse::Handled
998                        }
999                        WidgetEvent::KeyDown {
1000                            key: Key::PageUp, ..
1001                        } => {
1002                            let n = source.len();
1003                            if n == 0 {
1004                                return EventResponse::Handled;
1005                            }
1006                            if !is_open.get() {
1007                                open_overlay(ctx);
1008                            }
1009                            let current_idx = selected
1010                                .get()
1011                                .as_ref()
1012                                .and_then(|v| resolve_index(&source, v, &hint))
1013                                .unwrap_or(0);
1014                            let target = current_idx.saturating_sub(page_size);
1015                            pick_at(target, ctx);
1016                            EventResponse::Handled
1017                        }
1018                        // Type-ahead: letter/character keys jump to matching item.
1019                        WidgetEvent::KeyDown { key, .. } if key.to_char().is_some() => {
1020                            let ch = key.to_char().unwrap();
1021                            let mut ta = typeahead.borrow_mut();
1022                            let now = Instant::now();
1023                            // Reset the prefix once keystrokes fall outside the
1024                            // type-ahead window.
1025                            if now.duration_since(ta.1) > type_ahead_timeout {
1026                                ta.0.clear();
1027                            }
1028                            // Full Unicode lowercasing so accented input (e.g.
1029                            // 'É') matches accented labels — `to_ascii_lowercase`
1030                            // is a no-op on non-ASCII and would never match.
1031                            ta.0.extend(ch.to_lowercase());
1032                            ta.1 = now;
1033                            let prefix = ta.0.clone();
1034                            drop(ta);
1035
1036                            // Find first item whose label starts with the prefix
1037                            // (case-insensitive).
1038                            let n = source.len();
1039                            for i in 0..n {
1040                                if let Some(v) = source.get(i) {
1041                                    let label = (item_label_for_keys)(&v).resolve_now();
1042                                    if label.to_lowercase().starts_with(&prefix) {
1043                                        pick_at(i, ctx);
1044                                        break;
1045                                    }
1046                                }
1047                            }
1048                            EventResponse::Handled
1049                        }
1050                        _ => EventResponse::Ignored,
1051                    }
1052                }
1053            })
1054            .on_focus(move |gained: bool, _ctx: &mut EventContext| {
1055                is_focused_h.set(gained);
1056            })
1057            // `accessibility` advertises `Action::Click`; the dispatcher
1058            // routes an AT / automation click here rather than
1059            // synthesizing a pointer tap, so the dropdown must be opened
1060            // explicitly. Every platform adapter funnels activation
1061            // through `Click` (AT-SPI `DoAction(0)`, Windows Invoke,
1062            // macOS `accessibilityPerformPress`) — none sends
1063            // `Expand`/`Collapse` — so this is the only AT open path.
1064            .on_access_action({
1065                let open_overlay = open_overlay.clone();
1066                move |action, ctx: &mut EventContext| {
1067                    if action == teksilo_core::accesskit::Action::Click {
1068                        open_overlay(ctx);
1069                        EventResponse::Handled
1070                    } else {
1071                        EventResponse::Ignored
1072                    }
1073                }
1074            })
1075            // Focus walker skips disabled subtrees on its own.
1076            .focusable(true)
1077            .cursor(CursorIcon::Pointer);
1078
1079        ctx.apply_self_handlers(handler_set);
1080
1081        // Return BOTH the trigger root AND the dormant dropdown as
1082        // children so the framework links `dropdown_id` under this
1083        // widget in the arena instead of leaving it an orphan root.
1084        // Hit-test walks all arena roots; an orphan dormant subtree
1085        // can leak into hit-tests at fallback bounds and intercept
1086        // clicks meant for siblings. See popover_widget.rs for the
1087        // same pattern.
1088        vec![root_id, dropdown_id]
1089    }
1090
1091    fn layout_response(
1092        &self,
1093        proposal: SizeProposal,
1094        ctx: &LayoutContext,
1095    ) -> teksilo_core::widget::LayoutResponse {
1096        let min_height = crate::styles::recipe_combo_box_style::COMBO_BOX_HEIGHT;
1097        const MIN_WIDTH: f32 = 120.0;
1098        // Rigid: size to content (clamped to the combo's minimum), no shrink
1099        // (see Button's note). Wrap in `Shrinkable` to opt into compression.
1100        match self.root_child_id {
1101            Some(id) => {
1102                let child_size = ctx
1103                    .child_size(id, proposal)
1104                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1105                Size::new(
1106                    child_size.width.max(MIN_WIDTH),
1107                    child_size.height.max(min_height),
1108                )
1109            }
1110            None => proposal.resolve(MIN_WIDTH, min_height),
1111        }
1112        .into()
1113    }
1114
1115    fn place_children(
1116        &self,
1117        bounds: Rect,
1118        _proposal: SizeProposal,
1119        children: &mut [WidgetPlacement],
1120        _ctx: &LayoutContext,
1121    ) {
1122        // The trigger fills our bounds; the dropdown's bounds are
1123        // owned by the overlay manager when shown (`position_overlays`),
1124        // so we zero-size it here.
1125        for child in children.iter_mut() {
1126            if Some(child.id) == self.dropdown_content_id {
1127                child.size = teksilo_canvas::Size::ZERO;
1128                continue;
1129            }
1130            child.origin = bounds.origin();
1131            child.size = bounds.size();
1132        }
1133    }
1134
1135    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1136        builder.set_role(teksilo_core::accesskit::Role::ComboBox);
1137        builder.set_has_popup(teksilo_core::accesskit::HasPopup::Listbox);
1138
1139        if let Some(name) = self.label.as_ref() {
1140            builder.set_name(name.resolve_now());
1141        }
1142
1143        // A11y gap #3: use `placeholder` when nothing is selected, `value`
1144        // when something is. The two are distinct ARIA properties; screen
1145        // readers announce placeholders as hints rather than current values.
1146        match self.selected.get() {
1147            Some(v) => {
1148                let label = (self.item_label)(&v).resolve_now();
1149                if !label.is_empty() {
1150                    builder.set_value(label);
1151                }
1152            }
1153            None => {
1154                let ph = self.placeholder.resolve_now();
1155                if !ph.is_empty() {
1156                    builder.set_placeholder(ph);
1157                }
1158            }
1159        }
1160
1161        builder.set_expanded(self.is_open.get());
1162
1163        // Only set aria-controls when the popup is open — the listbox node is
1164        // absent from the tree when closed, and pointing at a missing node
1165        // causes AT crashes (VoiceOver unwrap in linked_ui_elements).
1166        if self.is_open.get()
1167            && let Some(popup_id) = self.dropdown_content_id
1168        {
1169            builder.push_controlled(widget_id_to_node_id(popup_id));
1170        }
1171
1172        // ARIA combobox pattern: when the popup is a filtered list, mark
1173        // `aria-autocomplete="list"` so assistive tech announces the
1174        // filter behavior. Only applied in searchable mode.
1175        if self.searchable {
1176            builder.set_auto_complete(teksilo_core::accesskit::AutoComplete::List);
1177        }
1178
1179        // Always advertise actions — framework gates them at dispatch
1180        // via `arena.is_enabled`, and the a11y walker handles
1181        // `set_disabled` from the same arena state.
1182        builder.add_action(teksilo_core::accesskit::Action::Click);
1183        builder.add_action(teksilo_core::accesskit::Action::Focus);
1184    }
1185
1186    fn children(&self) -> Vec<WidgetId> {
1187        let mut out = Vec::new();
1188        if let Some(id) = self.root_child_id {
1189            out.push(id);
1190        }
1191        if let Some(id) = self.dropdown_content_id {
1192            out.push(id);
1193        }
1194        out
1195    }
1196}
1197
1198/// Trigger-content wrapper used when the caller supplies
1199/// [`ComboBox::render_selected`]. Rebuilds its single child whenever the
1200/// selection (or locale) changes, so the custom selected-value view tracks
1201/// the selection without rebuilding the whole ComboBox. Laid out to fill the
1202/// slot the [`ComboBoxStyle`] gives it, exactly like the default text label.
1203struct SelectedContent<T: Clone + PartialEq + 'static> {
1204    selected: Signal<Option<T>>,
1205    render: Rc<dyn Fn(&T) -> Box<dyn Widget>>,
1206    placeholder: LocalizedString,
1207    placeholder_style: Option<teksilo_core::color_prop::TextStyleProp>,
1208    text_role: teksilo_core::color_prop::ColorProp,
1209    child: Option<WidgetId>,
1210}
1211
1212impl<T: Clone + PartialEq + 'static> std::fmt::Debug for SelectedContent<T> {
1213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1214        f.debug_struct("SelectedContent").finish_non_exhaustive()
1215    }
1216}
1217
1218impl<T: Clone + PartialEq + 'static> Widget for SelectedContent<T> {
1219    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1220        use teksilo_core::binding::BindingLevel;
1221        // Rebuild on selection change (new value → new custom view) and on
1222        // locale change (so the `None`-state placeholder re-translates).
1223        self.selected
1224            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1225        ctx.locale_signal()
1226            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1227
1228        let child = match self.selected.get() {
1229            Some(v) => ctx.add_boxed((self.render)(&v)),
1230            None => {
1231                let mut ph = TextWidget::new(self.placeholder.clone())
1232                    .color(self.text_role.clone())
1233                    .single_line();
1234                ph = match &self.placeholder_style {
1235                    Some(style) => ph.style(style.clone()),
1236                    None => ph.style(TextStyleRole::Body),
1237                };
1238                ctx.add(ph)
1239            }
1240        };
1241        self.child = Some(child);
1242        vec![child]
1243    }
1244
1245    fn layout_response(
1246        &self,
1247        proposal: SizeProposal,
1248        ctx: &LayoutContext,
1249    ) -> teksilo_core::widget::LayoutResponse {
1250        self.child
1251            .and_then(|id| ctx.child_size(id, proposal))
1252            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1253            .into()
1254    }
1255
1256    fn place_children(
1257        &self,
1258        bounds: Rect,
1259        _proposal: SizeProposal,
1260        children: &mut [WidgetPlacement],
1261        _ctx: &LayoutContext,
1262    ) {
1263        for child in children.iter_mut() {
1264            child.origin = bounds.origin();
1265            child.size = bounds.size();
1266        }
1267    }
1268
1269    fn children(&self) -> Vec<WidgetId> {
1270        self.child.into_iter().collect()
1271    }
1272}