Skip to main content

teksilo_widgets/
combo_box.rs

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