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