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