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