Skip to main content

herogpui_components/
combo_box.rs

1//! ComboBox — port of `@heroui/combo-box` (v3).
2//!
3//! A text input combined with a selectable list. Unlike
4//! [`Select`](crate::select::Select) the value is typed, and unlike
5//! [`Autocomplete`](crate::autocomplete::Autocomplete) the list can be opened
6//! without typing and `allowsCustomValue` decides whether input outside the
7//! collection is accepted.
8//!
9//! Pinned v3.2.4 / React Aria Components 1.20.0 keep a stable `Key` separate
10//! from each item's `textValue`: `selectedKey` / `defaultValue` /
11//! `disabledKeys`, the selection callbacks and the form value address items
12//! by key, while filtering and the visible text use the label. Items are
13//! therefore [`crate::PickerItem`]s; using a label as the key made duplicate
14//! labels alias each other's selection, disabled state and row identity.
15//! Pinned HeroUI's ComboBox composition adds only slots, trigger and popover
16//! chrome on the RAC primitive, so it overrides none of this.
17//!
18//! The input text is the query and the selected item's label, never the key:
19//! picking a row fills the field with that row's label, and the cursor and
20//! row identities ride the key so duplicate labels stay distinct. A committed
21//! custom value carries a null selected key — pinned react-stately 3.49.0's
22//! `commitCustomValue` sets the value to `null` and keeps the typed text —
23//! which the [`ComboBox::on_selection_change_all`] slice reports as an empty
24//! selection, and only when a selection actually existed; the single-key
25//! [`ComboBox::on_selection_change`] cannot spell `null` and stays silent there.
26//!
27//! `formValue` (pinned React Aria Components 1.20.0) defaults to `"key"`: a
28//! named field submits the selected key(s). `allowsCustomValue` forces
29//! `"text"`, submitting the typed text instead.
30
31use std::{
32    cell::{Cell, RefCell},
33    collections::HashMap,
34    rc::{Rc, Weak},
35    sync::Arc,
36};
37
38use gpui::{
39    div, prelude::*, px, App, Entity, InteractiveElement, IntoElement, Pixels, RenderOnce,
40    SharedString, Styled, Window,
41};
42use herogpui_core::{element_id, FieldVariant, Placement, SelectionMode};
43use herogpui_theme::ActiveTheme;
44
45use crate::{
46    a11y::{self, A11y as _},
47    icons,
48    input::{Input, InputState},
49    matches::{empty_matches, MatchesCache},
50    picker_item::PickerItem,
51    selection::{normalize_selection, toggle_key},
52    util,
53};
54
55/// The port's local floor on the root field stack when `fullWidth` is off:
56/// without a placeholder the inner input has no intrinsic width and the
57/// trigger would collapse to just its chevron.
58const TRIGGER_MIN_WIDTH: Pixels = px(180.);
59
60/// When the suggestion list opens.
61///
62/// v3's table reads `"focus" | "input" | "manual"` with **`"focus"`** as the
63/// default, so a v3 ComboBox shows its list as soon as the field is focused.
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
65pub enum MenuTrigger {
66    /// Open as soon as the field gains focus. v3's default.
67    #[default]
68    Focus,
69    /// Open on input, and when the trigger button is pressed.
70    Input,
71    /// Open only when the trigger button is pressed.
72    Manual,
73}
74
75/// `formValue` — what a `name`d ComboBox submits.
76///
77/// Pinned React Aria Components 1.20.0 defaults to key and forces text when
78/// `allowsCustomValue` is set: an input that accepts values outside the
79/// collection always submits what was typed.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub enum ComboBoxFormValue {
82    /// The selected item key(s). The pinned default.
83    #[default]
84    Key,
85    /// The typed input text.
86    Text,
87}
88
89type OnSelectionChange = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
90type OnOpenChange = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
91type ComboBoxFormState = Rc<RefCell<crate::form::LiveFormFieldState>>;
92
93thread_local! {
94    static COMBO_BOX_FORM_STATES: RefCell<HashMap<u64, Weak<RefCell<crate::form::LiveFormFieldState>>>> =
95        RefCell::new(HashMap::new());
96}
97
98fn combo_box_form_state(entity_id: u64) -> ComboBoxFormState {
99    COMBO_BOX_FORM_STATES.with(|states| {
100        let mut states = states.borrow_mut();
101        if let Some(state) = states.get(&entity_id).and_then(|state| state.upgrade()) {
102            return state;
103        }
104        let state = Rc::new(RefCell::new(crate::form::LiveFormFieldState {
105            value: crate::form::FormValue::Keys(Vec::new()),
106            is_invalid: false,
107            is_successful: true,
108            focus: None,
109            restore: None,
110        }));
111        states.insert(entity_id, Rc::downgrade(&state));
112        state
113    })
114}
115
116/// Whether the pinned `formValue` serialization submits the input text:
117/// `formValue="text"` explicitly, or `allowsCustomValue`, which pinned React
118/// Aria Components 1.20.0 turns into text regardless of the prop.
119fn form_submits_text(form_value: Option<ComboBoxFormValue>, allows_custom_value: bool) -> bool {
120    allows_custom_value || form_value == Some(ComboBoxFormValue::Text)
121}
122
123/// The one-shot that keeps `MenuTrigger::Focus` from reopening a panel the
124/// user dismissed while the field still has the focus.
125///
126/// `can_open` is true while a fresh focus session may open the list; opening
127/// consumes it. `was_open` remembers the flag from the last frame, so a panel
128/// that closes while the field stays focused (Escape, a pick, the chevron, a
129/// press outside) reads as a dismissal rather than as a new focus to answer.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131struct FocusOpen {
132    can_open: bool,
133    was_open: bool,
134}
135
136/// Which suggestion the keyboard is on, held as the item's *key* so the
137/// cursor stays on the same item when the query filters the list or the
138/// caller reorders the collection. `hidden_query` retains the cursor across a
139/// multiple-mode pick, which clears the query before the next frame can
140/// re-derive the row: while the query equals the retained value the cursor
141/// stays alive even when the capped or filtered list no longer renders its
142/// item.
143#[derive(Clone, Debug, PartialEq, Eq)]
144struct ComboCursor {
145    key: SharedString,
146    hidden_query: Option<String>,
147}
148
149fn cursor_for(rows: &[PickerItem], index: usize, hidden_query: Option<String>) -> ComboCursor {
150    ComboCursor {
151        key: rows[index].key().clone(),
152        hidden_query,
153    }
154}
155
156fn cursor_position(rows: &[PickerItem], cursor: &ComboCursor) -> Option<usize> {
157    rows.iter().position(|item| item.key() == &cursor.key)
158}
159
160/// The label an item key resolves to, or `None` when the key has no item in
161/// the collection (still loading, or already removed).
162fn label_of_key<'a>(items: &'a [PickerItem], key: &SharedString) -> Option<&'a SharedString> {
163    items
164        .iter()
165        .find(|item| item.key() == key)
166        .map(|item| item.label())
167}
168
169/// The rows the panel shows for `query`: the full collection under the
170/// full-collection flag (which owns the frame, so no filter runs), otherwise
171/// the custom `defaultFilter`'s decision — including what an empty query
172/// means — or the default case-insensitive substring match. Filtering reads
173/// the items' labels, never their keys.
174fn compute_matches(
175    items: &[PickerItem],
176    query: &str,
177    max_items: usize,
178    full: bool,
179    filter: Option<&Arc<dyn Fn(&str, &str) -> bool + 'static>>,
180) -> Vec<PickerItem> {
181    if full {
182        return items.iter().take(max_items).cloned().collect();
183    }
184    if let Some(filter) = filter {
185        return items
186            .iter()
187            .filter(|item| filter(item.label(), query))
188            .take(max_items)
189            .cloned()
190            .collect();
191    }
192    if query.is_empty() {
193        return items.iter().take(max_items).cloned().collect();
194    }
195    let lowered = query.to_lowercase();
196    items
197        .iter()
198        .filter(|item| item.label().to_lowercase().contains(&lowered))
199        .take(max_items)
200        .cloned()
201        .collect()
202}
203
204/// HeroUI ComboBox (controlled open state).
205#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
206#[derive(IntoElement)]
207pub struct ComboBox {
208    state: Entity<InputState>,
209    items: Vec<PickerItem>,
210    /// `isOpen` — `None` leaves the component holding the flag, seeded from
211    /// `defaultOpen`.
212    is_open: Option<bool>,
213    default_open: bool,
214    placement: Placement,
215    label: Option<SharedString>,
216    placeholder: Option<SharedString>,
217    description: Option<SharedString>,
218    error_message: Option<SharedString>,
219    variant: FieldVariant,
220    menu_trigger: MenuTrigger,
221    /// `defaultFilter` — decides whether an item matches the query. Receives
222    /// the item's label; the key never reaches it.
223    filter: Option<Arc<dyn Fn(&str, &str) -> bool + 'static>>,
224    allows_custom_value: bool,
225    max_items: usize,
226    full_width: bool,
227    /// The root's width floor while not `full_width`; the default is 180px.
228    min_width: Option<Pixels>,
229    /// Optional trigger geometry/chrome overrides; defaults are the stock box.
230    field: util::FieldBox,
231    is_disabled: bool,
232    is_invalid: bool,
233    is_required: bool,
234    is_read_only: bool,
235    /// `autoFocus` — take focus on the first render.
236    auto_focus: bool,
237    /// `ListLayout`'s `rowHeight`, which virtualizes the popover list.
238    row_height: Option<Pixels>,
239    /// Replaces the list rows' `px-2` horizontal padding.
240    row_padding_x: Option<Pixels>,
241    /// Replaces the list rows' `py-1.5` vertical padding.
242    row_padding_y: Option<Pixels>,
243    /// The fill a hovered row takes, in place of `--default`.
244    row_hover_bg: Option<gpui::Hsla>,
245    /// The family the option rows are drawn with; unset keeps the
246    /// inherited family. A detached popover does not inherit the trigger's
247    /// font.
248    row_font_family: Option<SharedString>,
249    /// The trigger field text's family, forwarded to the inner `Input`.
250    font_family: Option<SharedString>,
251    /// The corner radius of the detached panel, in place of the owning
252    /// `container_radius` helper.
253    radius: Option<Pixels>,
254    /// `validate` — run by the component, not the caller.
255    validate: Option<crate::validation::Validator<str>>,
256    /// `validationBehavior` — carried on the inner field.
257    validation_behavior: Option<crate::form::ValidationBehavior>,
258    /// `allowsEmptyCollection` — keeps the panel up with no matches.
259    allows_empty_collection: bool,
260    /// `name` — the name this field submits under.
261    name: Option<SharedString>,
262    /// `formValue` — `None` is the pinned `"key"` default; `allowsCustomValue`
263    /// forces text whatever this says.
264    form_value: Option<ComboBoxFormValue>,
265    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
266    should_focus_wrap: bool,
267    /// `ListBox.Section` — a heading above the item with this key.
268    sections: Vec<(SharedString, SharedString)>,
269    /// `ListBox.ItemIndicator` — draws the tick. The closure is handed whether
270    /// the row is the selected one.
271    indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
272    disabled_keys: std::collections::HashSet<SharedString>,
273    selection_mode: SelectionMode,
274    /// The selection as ordered unique item keys — pinned react-stately
275    /// 3.49.0's `selectedKeys` is a JS `Set`, which iterates in insertion
276    /// order, so the callbacks, the form value and `ComboBox.Value` follow
277    /// the order the keys were picked (or the owner listed) in.
278    selected_keys: Vec<SharedString>,
279    /// Whether the caller drives the selection. An unset `selected_keys` is not
280    /// an empty controlled selection: without this flag every plain ComboBox
281    /// would hand its own picks back to a set nobody owns, and
282    /// `ComboBox.Value` would never see them.
283    is_controlled: bool,
284    /// Whether `selected_key` owns the controlled single key. Only then does
285    /// the render sync the input text to the key's label — and only when the
286    /// key changes, never on the owner's other re-renders.
287    selected_key_sync: bool,
288    /// `ComboBox.Value` — draws the chosen item under the field.
289    value_content: Option<Box<dyn Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static>>,
290    /// `defaultValue` — set it to hand this component its own selection.
291    default_value: Option<Vec<SharedString>>,
292    /// `defaultInputValue` — seeds the text state on the first render only.
293    default_input_value: Option<SharedString>,
294    /// `value` — the v3 alias of the controlled input text, stored for the
295    /// first render only and winning over `defaultInputValue`.
296    value: Option<SharedString>,
297    on_selection_change_all: Option<Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
298    on_input_change: Option<Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>>,
299    on_selection_change: Option<OnSelectionChange>,
300    on_open_change: Option<OnOpenChange>,
301    form_state: ComboBoxFormState,
302    /// The `sx` slot, refined over the root style at the end of render.
303    sx: Option<Box<gpui::StyleRefinement>>,
304}
305
306impl ComboBox {
307    /// `selectionMode`
308    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
309        self.selection_mode = mode;
310        self
311    }
312
313    /// `defaultValue` — the uncontrolled initial selection, as item keys.
314    ///
315    /// Supplying it hands the component its own selection set, seeded once;
316    /// [`Self::selected_keys`] is the controlled spelling. The listed order is
317    /// the selection's order, exactly as the owner listed it.
318    /// `defaultInputValue` — the uncontrolled initial text.
319    ///
320    /// Written into the state on the first render only; [`Self::input_value`]
321    /// is the controlled spelling.
322    pub fn default_input_value(mut self, text: impl Into<SharedString>) -> Self {
323        self.default_input_value = Some(text.into());
324        self
325    }
326
327    /// `defaultSelectedKeys` — the uncontrolled initial selection, as item keys.
328    pub fn default_value(
329        mut self,
330        keys: impl IntoIterator<Item = impl Into<SharedString>>,
331    ) -> Self {
332        self.default_value = Some(keys.into_iter().map(Into::into).collect());
333        self
334    }
335
336    /// `selectedKeys` — the controlled selection, as item keys. The listed
337    /// order is the owner's order and is preserved everywhere the selection
338    /// is read.
339    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
340        self.selected_keys = keys.into_iter().collect();
341        self.is_controlled = true;
342        self
343    }
344
345    /// Reports the whole selection: one key for a changed single pick by
346    /// pointer, Enter or Tab, and all keys in multiple mode. Re-picking the
347    /// current single key does not report a value change. A cleared input or
348    /// committed custom value reports an empty slice only when a selection
349    /// existed, matching React Stately 3.50.0's `onChange`/`null` contract.
350    pub fn on_selection_change_all(
351        mut self,
352        handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
353    ) -> Self {
354        self.on_selection_change_all = Some(Arc::new(handler));
355        self
356    }
357
358    /// `selectedKey` — the controlled single-selection key, or `null` spelled
359    /// as the empty string. The selection rides the key; the input text is
360    /// synced to the key's label only when that key actually changes, so an
361    /// owner that passes the same key every render never clobbers the text
362    /// being typed — pinned react-stately resets the input value when the
363    /// selected key changes and leaves the input alone otherwise.
364    pub fn selected_key(mut self, key: impl Into<String>) -> Self {
365        let key = SharedString::from(key.into());
366        self.selected_keys = if key.is_empty() {
367            Vec::new()
368        } else {
369            vec![key]
370        };
371        self.is_controlled = true;
372        self.selected_key_sync = true;
373        self
374    }
375
376    /// `value` — the v3 alias of [`ComboBox::input_value`], the controlled
377    /// input text, as a pure builder.
378    pub fn value(self, value: impl Into<String>) -> Self {
379        self.input_value(value)
380    }
381
382    /// `disabledKeys` — keys of the items that render but cannot be chosen.
383    /// Disabled state is per key, so one of two same-label items can be
384    /// disabled alone.
385    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
386        self.disabled_keys = keys.into_iter().collect();
387        self
388    }
389
390    /// `autoFocus` — take focus on the first render.
391    /// `ListLayout`'s `rowHeight` -- and what virtualizes the popover list.
392    ///
393    /// v3 wraps the list in `<Virtualizer layout={ListLayout}>` inside the
394    /// popover; a uniform [`VirtualList`](crate::VirtualList) builds only the rows in view, and it can do
395    /// that because every row is this tall.
396    pub fn row_height(mut self, h: impl Into<Pixels>) -> Self {
397        self.row_height = Some(h.into());
398        self
399    }
400
401    /// Focuses the input when the combo box mounts.
402    pub fn auto_focus(mut self, v: bool) -> Self {
403        self.auto_focus = v;
404        self
405    }
406
407    /// `validate` — returns the message to show, or `None` when the text is
408    /// fine. The component runs it and surfaces the result.
409    pub fn validate(mut self, f: impl Fn(&str) -> Option<SharedString> + 'static) -> Self {
410        self.validate = Some(Arc::new(f));
411        self
412    }
413
414    /// `validationBehavior` — `Allow` shows the message without blocking a
415    /// form submission.
416    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
417        self.validation_behavior = Some(behavior);
418        self
419    }
420
421    /// `allowsEmptyCollection` — keeps the panel open when nothing matches.
422    pub fn allows_empty_collection(mut self, v: bool) -> Self {
423        self.allows_empty_collection = v;
424        self
425    }
426
427    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
428    pub fn should_focus_wrap(mut self, v: bool) -> Self {
429        self.should_focus_wrap = v;
430        self
431    }
432
433    /// `ListBox.Section` — a heading rendered above the item with this key.
434    pub fn section_before(
435        mut self,
436        item: impl Into<SharedString>,
437        label: impl Into<SharedString>,
438    ) -> Self {
439        self.sections.push((item.into(), label.into()));
440        self
441    }
442
443    /// `ListBox.ItemIndicator` — draw the selected tick yourself.
444    pub fn indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
445        self.indicator = Some(Box::new(render));
446        self
447    }
448
449    /// `ComboBox.Value` — draw the chosen item under the field.
450    ///
451    /// v3's `.combo-box__value` is an optional part (`text-sm
452    /// text-field-foreground empty:hidden`), and the closure is handed the same
453    /// render props the component passes down: `selectedItems` reaches
454    /// `selected_items`, and `defaultChildren` is the row this port would draw.
455    pub fn value_content(
456        mut self,
457        render: impl Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static,
458    ) -> Self {
459        self.value_content = Some(Box::new(render));
460        self
461    }
462
463    /// `name` — the name this field submits under.
464    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
465        self.name = Some(name.into());
466        self
467    }
468
469    /// `formValue` — whether a `name`d field submits the selected key(s) or
470    /// the typed text. `allowsCustomValue` forces the text whatever this says,
471    /// as pinned React Aria Components 1.20.0 does.
472    pub fn form_value(mut self, form_value: ComboBoxFormValue) -> Self {
473        self.form_value = Some(form_value);
474        self
475    }
476
477    /// The `Form` field this control submits, when it has a `name`.
478    ///
479    /// v3 discovers a field through the DOM; gpui gives a child no way to
480    /// reach its ancestor, so the control hands the pair over instead. The
481    /// live value follows the pinned `formValue` serialization — the selected
482    /// key(s) by default, the typed text under `allowsCustomValue` — and the
483    /// input keeps the implicit-Enter submission of the text control it is.
484    /// A disabled control stays registered and is omitted from FormData.
485    pub fn form_field(&self) -> Option<crate::form::FormField> {
486        let name = self.name.clone()?;
487        Some(
488            crate::form::FormField::live_text(name, self.form_state.clone(), self.state.clone())
489                .is_required(self.is_required),
490        )
491    }
492
493    /// Makes the field read-only (v3 `isReadOnly`).
494    pub fn is_read_only(mut self, v: bool) -> Self {
495        self.is_read_only = v;
496        self
497    }
498
499    /// `inputValue` — the controlled input text, as a pure builder.
500    ///
501    /// The bound [`crate::InputState`] owns the text once the field renders,
502    /// so this seeds the state on the first render only, winning over
503    /// [`ComboBox::default_input_value`] the way v3's controlled prop outranks
504    /// the uncontrolled seed; calling `.input_value(..)` twice keeps the last
505    /// call, like every other builder here. [`ComboBox::value`] is v3's alias
506    /// and writes the same slot. A later value is an imperative update rather
507    /// than a builder: `state.update(cx, |s, _| s.set_value(..))`.
508    pub fn input_value(mut self, value: impl Into<String>) -> Self {
509        self.value = Some(SharedString::from(value.into()));
510        self
511    }
512
513    /// `onInputChange` — fires as the text changes, where
514    /// [`ComboBox::on_selection_change`] fires only on a pick.
515    pub fn on_input_change(
516        mut self,
517        handler: impl Fn(&str, &mut Window, &mut App) + 'static,
518    ) -> Self {
519        self.on_input_change = Some(Arc::new(handler));
520        self
521    }
522
523    /// Single-key convenience alias for [`ComboBox::on_selection_change`].
524    /// Use [`Self::on_selection_change_all`] for the complete `onChange` value,
525    /// including an empty selection and multiple keys.
526    pub fn on_change(
527        self,
528        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
529    ) -> Self {
530        self.on_selection_change(handler)
531    }
532
533    /// Creates a combo box over the given input state and items.
534    pub fn new(state: Entity<InputState>, items: Vec<PickerItem>) -> Self {
535        let form_state = combo_box_form_state(state.entity_id().as_u64());
536        Self {
537            state,
538            items,
539            is_open: None,
540            default_open: false,
541            placement: Placement::BottomStart,
542            label: None,
543            placeholder: None,
544            description: None,
545            error_message: None,
546            variant: FieldVariant::Primary,
547            menu_trigger: MenuTrigger::Focus,
548            filter: None,
549            allows_custom_value: false,
550            max_items: 8,
551            full_width: false,
552            min_width: None,
553            is_disabled: false,
554            is_invalid: false,
555            is_required: false,
556            is_read_only: false,
557            auto_focus: false,
558            row_height: None,
559            row_padding_x: None,
560            row_padding_y: None,
561            row_hover_bg: None,
562            row_font_family: None,
563            font_family: None,
564            radius: None,
565            field: util::FieldBox::default(),
566            validate: None,
567            validation_behavior: None,
568            allows_empty_collection: false,
569            name: None,
570            form_value: None,
571            should_focus_wrap: false,
572            sections: Vec::new(),
573            indicator: None,
574            disabled_keys: std::collections::HashSet::new(),
575            selection_mode: SelectionMode::Single,
576            selected_keys: Vec::new(),
577            is_controlled: false,
578            selected_key_sync: false,
579            value_content: None,
580            default_value: None,
581            default_input_value: None,
582            value: None,
583            on_selection_change_all: None,
584            on_input_change: None,
585            on_selection_change: None,
586            on_open_change: None,
587            form_state,
588            sx: None,
589        }
590    }
591
592    /// `placement` on `ComboBox.Popover`.
593    pub fn placement(mut self, placement: Placement) -> Self {
594        self.placement = placement;
595        self
596    }
597
598    /// Sets the controlled open state (v3 `isOpen`).
599    pub fn is_open(mut self, v: bool) -> Self {
600        self.is_open = Some(v);
601        self
602    }
603    /// `defaultOpen` — the uncontrolled initial state.
604    ///
605    /// Only consulted when `is_open` is not supplied; the component then owns
606    /// the flag and its trigger toggles it.
607    pub fn default_open(mut self, v: bool) -> Self {
608        self.default_open = v;
609        self
610    }
611
612    /// Sets the field label.
613    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
614        self.label = Some(text.into());
615        self
616    }
617
618    /// Sets the input placeholder text.
619    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
620        self.placeholder = Some(text.into());
621        self
622    }
623
624    /// Sets the description shown beneath the field.
625    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
626        self.description = Some(text.into());
627        self
628    }
629
630    /// Sets the error message shown when the field is invalid.
631    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
632        self.error_message = Some(text.into());
633        self
634    }
635
636    /// Sets the field's visual variant.
637    pub fn variant(mut self, variant: FieldVariant) -> Self {
638        self.variant = variant;
639        self
640    }
641
642    /// Sets when the list opens (v3 `menuTrigger`); the default is on focus.
643    pub fn menu_trigger(mut self, trigger: MenuTrigger) -> Self {
644        self.menu_trigger = trigger;
645        self
646    }
647
648    /// `defaultFilter` — replaces the default case-insensitive substring
649    /// match.
650    ///
651    /// Called as `filter(item_label, input)`: v3 filters on the item's
652    /// `textValue`, never on its key, and owns the whole decision —
653    /// including what an empty query means.
654    pub fn filter(mut self, f: impl Fn(&str, &str) -> bool + 'static) -> Self {
655        self.filter = Some(Arc::new(f));
656        self
657    }
658
659    /// `allowsCustomValue` — accept text that matches no item. A committed
660    /// custom value keeps the typed text and carries a null selected key.
661    pub fn allows_custom_value(mut self, v: bool) -> Self {
662        self.allows_custom_value = v;
663        self
664    }
665
666    /// Caps the number of items shown in the list (minimum 1; default 8).
667    pub fn max_items(mut self, n: usize) -> Self {
668        self.max_items = n.max(1);
669        self
670    }
671
672    /// Makes the combo box fill its container's width.
673    pub fn full_width(mut self, v: bool) -> Self {
674        self.full_width = v;
675        self
676    }
677
678    /// Replaces the 180px width floor the combo box root keeps while it is not
679    /// [`ComboBox::full_width`], so a compact toolbar filter can go narrower
680    /// or a wide one keep a larger floor. A full-width combo box has no floor
681    /// either way, as before. Not a v3 prop; v3 sizes the root with a class.
682    pub fn min_width(mut self, width: impl Into<Pixels>) -> Self {
683        self.min_width = Some(width.into());
684        self
685    }
686
687    /// Replaces the trigger's 36px box height (forwarded to the inner field).
688    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
689        self.field.height = Some(h.into());
690        self
691    }
692
693    /// Replaces the trigger's `px-3` horizontal padding (forwarded to the
694    /// inner field).
695    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
696        self.field.padding_x = Some(p.into());
697        self
698    }
699
700    /// Renders the trigger with no background, border, field shadow or focus
701    /// ring, for a caller painting around it. The list still opens.
702    pub fn is_bare(mut self, v: bool) -> Self {
703        self.field.is_bare = v;
704        self.field.is_bare_is_set = true;
705        self
706    }
707
708    /// Shows or hides only the editable trigger's visual focus ring. The
709    /// trigger stays focusable and the list still opens when set to `false`.
710    pub fn focus_ring(mut self, v: bool) -> Self {
711        self.field.focus_ring = Some(v);
712        self
713    }
714
715    /// Replaces the list rows' `px-2` horizontal padding.
716    pub fn row_padding_x(mut self, p: impl Into<Pixels>) -> Self {
717        self.row_padding_x = Some(p.into());
718        self
719    }
720
721    /// Replaces the list rows' `py-1.5` vertical padding.
722    pub fn row_padding_y(mut self, p: impl Into<Pixels>) -> Self {
723        self.row_padding_y = Some(p.into());
724        self
725    }
726
727    /// The fill a hovered row takes, in place of `--default`.
728    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
729        self.row_hover_bg = Some(color.into());
730        self
731    }
732
733    /// The trigger field's font family, forwarded to the inner
734    /// [`crate::Input`] so the query, placeholder and caret measurement all
735    /// use it (see [`crate::Input::font_family`]), and set on the root so the
736    /// `ComboBox.Value` line inherits it; unset keeps the inherited family.
737    /// The detached rows take [`ComboBox::row_font_family`] instead. Not a v3
738    /// prop; v3 sets it with a class.
739    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
740        self.font_family = Some(family.into());
741        self
742    }
743
744    /// The family the option rows are drawn with; unset keeps the inherited
745    /// family. A detached popover does not inherit the trigger's font.
746    pub fn row_font_family(mut self, family: impl Into<SharedString>) -> Self {
747        self.row_font_family = Some(family.into());
748        self
749    }
750
751    /// The corner radius of the detached panel, in place of the owning
752    /// `container_radius` helper. The panel's entry zoom interpolates the same
753    /// value, so both follow the override. Not a v3 prop; the removed v2
754    /// `radius` prop is prohibited and this is a per-component repository
755    /// extension.
756    ///
757    /// The trigger is an inner [`crate::Input`] whose box the shared field
758    /// chrome paints — `--field-radius`, not this value.
759    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
760        self.radius = Some(radius.into());
761        self
762    }
763
764    /// The one slot for caller-owned low-level styling: GPUI's styling methods
765    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
766    /// applied to the combo box's root element after every value the variant
767    /// and the active theme chose, so they win. The field paints its own
768    /// chrome, so this reaches the box that chrome sits in, not the chrome.
769    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
770        util::refine_sx(&mut self.sx, style);
771        self
772    }
773
774    /// Disables the combo box (v3 `isDisabled`).
775    pub fn is_disabled(mut self, v: bool) -> Self {
776        self.is_disabled = v;
777        self
778    }
779
780    /// Marks the field invalid (v3 `isInvalid`).
781    pub fn is_invalid(mut self, v: bool) -> Self {
782        self.is_invalid = v;
783        self
784    }
785
786    /// Marks the field required (v3 `isRequired`).
787    pub fn is_required(mut self, v: bool) -> Self {
788        self.is_required = v;
789        self
790    }
791
792    /// Pick-only single-key convenience callback.
793    ///
794    /// Use [`Self::on_selection_change_all`] for v3's complete
795    /// `onChange` domain, including multiple selection and the `null` a
796    /// cleared input or a committed custom value reports.
797    pub fn on_selection_change(
798        mut self,
799        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
800    ) -> Self {
801        self.on_selection_change = Some(Arc::new(handler));
802        self
803    }
804
805    /// Called with the new open state when the list opens or closes (v3 `onOpenChange`).
806    pub fn on_open_change(
807        mut self,
808        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
809    ) -> Self {
810        self.on_open_change = Some(Arc::new(handler));
811        self
812    }
813}
814
815type ComboAction = Arc<dyn Fn(&mut Window, &mut App)>;
816type OnSelectionChangeAll = Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
817type OnInputChange = Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>;
818type ComboFilter = Arc<dyn Fn(&str, &str) -> bool + 'static>;
819
820/// The controlled halves `render` resolves first: the frame's base id and
821/// shared collection, the selection and the open flag. `controlled` takes
822/// `cx` mutably, so these precede every other read.
823struct ComboControlled {
824    entity_id: u64,
825    base_id: gpui::ElementId,
826    items: Rc<[PickerItem]>,
827    multiple: bool,
828    default_selection: Vec<SharedString>,
829    selection_own: Option<Entity<Vec<SharedString>>>,
830    open_state: bool,
831    open_own: Option<Entity<bool>>,
832    overlay_phase: util::OverlayPhase,
833    dismissal_token: util::OverlayToken,
834}
835
836/// Everything one ComboBox frame shares between its painted parts: the
837/// controlled state, the resolved matches and cursor, the keyed handles and
838/// the shared close/commit actions. `render` resolves it once in
839/// [`ComboBox::frame`]; the chevron, the input, the field row, the key
840/// handler and the popover list all read this one copy.
841struct ComboFrame {
842    entity_id: u64,
843    base_id: gpui::ElementId,
844    items: Rc<[PickerItem]>,
845    multiple: bool,
846    selection_own: Option<Entity<Vec<SharedString>>>,
847    open_state: bool,
848    open_own: Option<Entity<bool>>,
849    overlay_phase: util::OverlayPhase,
850    dismissal_token: util::OverlayToken,
851    resolved_placement: Rc<Cell<Option<Placement>>>,
852    entry_placement: Placement,
853    anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>>,
854    colors: herogpui_theme::ThemeColors,
855    layout: herogpui_theme::LayoutTheme,
856    container_radius: Pixels,
857    close_open: ComboAction,
858    raw_query: String,
859    is_invalid: bool,
860    focus_handle: gpui::FocusHandle,
861    show_all_items: Entity<bool>,
862    display_full_collection: bool,
863    cursor: Entity<Option<ComboCursor>>,
864    matches: Rc<[PickerItem]>,
865    focus_open: Option<Entity<FocusOpen>>,
866    inside_pressed: Rc<Cell<bool>>,
867    cursor_at: Option<usize>,
868    list_scroll_now: crate::VirtualListHandle,
869    panel_scroll_now: gpui::ScrollHandle,
870    commit_value: ComboAction,
871    blur_scope: util::FocusLeave,
872    blur_focus: gpui::FocusHandle,
873}
874
875impl RenderOnce for ComboBox {
876    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
877        // One structured base id for the whole frame: every part below hangs
878        // off it, so a hyphenated part name can never fold into a sibling's
879        // key. Its `Display` is `combobox-<entity id>`, the spelling the parts
880        // used to build with `format!`.
881        let entity_id = self.state.entity_id().as_u64();
882        let base_id = gpui::ElementId::named_usize("combobox", entity_id as usize);
883        // One shared collection for the frame: the `'static` panel and event
884        // closures clone the `Rc`, never the rows.
885        let items: Rc<[PickerItem]> = std::mem::take(&mut self.items).into();
886
887        // `value` / `defaultInputValue` seed the text once, before anything
888        // reads it. `value` is v3's controlled spelling, so it outranks the
889        // uncontrolled seed; the state owns the text afterwards, and
890        // `InputState::set_value` is the imperative update.
891        let seed = self.value.clone().or(self.default_input_value.clone());
892        if let Some(text) = seed {
893            let state = self.state.clone();
894            util::seed_once(
895                window,
896                cx,
897                element_id::scoped(&base_id, "default-text"),
898                move |cx| {
899                    state.update(cx, |s, cx| {
900                        s.set_value(text.to_string());
901                        cx.notify();
902                    });
903                },
904            );
905        }
906
907        // `defaultValue` opts into the component holding its own selection;
908        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
909        // Both spellings normalize to the `Set` shape first: duplicates
910        // collapse to their first insertion, and single mode keeps one key.
911        // A controlled order is the owner's order — nothing is sorted.
912        let multiple = self.selection_mode == SelectionMode::Multiple;
913        self.selected_keys = normalize_selection(self.selected_keys.clone(), multiple);
914        let default_selection =
915            normalize_selection(self.default_value.clone().unwrap_or_default(), multiple);
916        let (selection, selection_own) = util::controlled(
917            window,
918            cx,
919            element_id::scoped(&base_id, "selection"),
920            self.is_controlled.then(|| self.selected_keys.clone()),
921            default_selection.clone(),
922        );
923        self.selected_keys = selection;
924
925        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
926        let (open_state, open_own) = util::controlled(
927            window,
928            cx,
929            element_id::scoped(&base_id, "open"),
930            self.is_open,
931            self.default_open,
932        );
933        let (overlay_phase, dismissal_token) = util::overlay_scope(
934            window,
935            cx,
936            element_id::scoped(&base_id, "overlay"),
937            open_state,
938            true,
939        );
940        let overlay_active = overlay_phase != util::OverlayPhase::Closed;
941        let frame = self.frame(
942            ComboControlled {
943                entity_id,
944                base_id,
945                items,
946                multiple,
947                default_selection,
948                selection_own,
949                open_state,
950                open_own,
951                overlay_phase,
952                dismissal_token,
953            },
954            window,
955            cx,
956        );
957        let trigger = self.trigger(&frame, window, cx);
958        let mut input = self.input(&frame, trigger);
959        let ComboFrame {
960            entity_id,
961            ref base_id,
962            ref anchor_bounds,
963            ref inside_pressed,
964            ..
965        } = frame;
966        // `.combo-box__input-group` is the field itself (`relative
967        // inline-flex items-center`). The popup anchors to the Input's 36px
968        // field row — not to the label-to-value wrapper root — the way RAC's
969        // `triggerRef` reads `groupRef.current || inputRef.current` (pinned
970        // 1.20.0 `dist/private/ComboBox.js`). `.combo-box__value` sits below
971        // the field inside the root, so a value row that appears under it
972        // must not push the popover down. `scrollable_field_popover` below
973        // reads these bounds to flip and cap the panel; the measure element
974        // inside `Input` only records them.
975        let inside_pressed_for_group = inside_pressed.clone();
976        let field_selector = format!("combobox-field-{entity_id}");
977        input = input.field_anchor(anchor_bounds.clone(), field_selector);
978        let input_group = div()
979            .id(element_id::scoped(base_id, "field"))
980            // `combo-box/combo-box.js`'s `ComboBox.InputGroup` renders RAC
981            // `Group`, and `react-aria-components/dist/private/Group.mjs` is
982            // `role: props.role ?? 'group'`.
983            //
984            // The `role="combobox"` that `useComboBox` puts on the *input*
985            // does not appear anywhere in this port: the field is a
986            // `crate::input::Input`, whose role is decided inside its own
987            // render from its `InputType` and which has no override prop.
988            // See the picker note in `crate::a11y`.
989            .a11y(a11y::Role::Group)
990            .relative()
991            .capture_any_mouse_down(move |_, _, cx| {
992                inside_pressed_for_group.set(true);
993                let pressed = inside_pressed_for_group.clone();
994                cx.defer(move |_| pressed.set(false));
995            })
996            .child(input.render(window, cx));
997        let mut root = div()
998            .when(!self.full_width, |e| {
999                e.min_w(self.min_width.unwrap_or(TRIGGER_MIN_WIDTH))
1000            })
1001            .relative()
1002            .flex()
1003            .flex_col()
1004            .gap(px(4.))
1005            // The value line and label inherit the family; the field takes it
1006            // explicitly above for its caret measurement.
1007            .when_some(self.font_family.clone(), |e, family| e.font_family(family));
1008        let value_content = self.value_row(&frame);
1009
1010        // `allowsEmptyCollection` keeps the panel up with no matches. Without
1011        // it an empty result closes the list; `allowsCustomValue` only changes
1012        // what Enter commits while the list is closed.
1013        // Up, down, Home, End and Enter walk the suggestions; the inner input
1014        // keeps left and right for the caret.
1015        if !self.is_disabled && !self.is_read_only {
1016            root = self.root_keys(root, &frame);
1017        }
1018
1019        let show_list = overlay_active
1020            && !self.is_disabled
1021            && (!frame.matches.is_empty() || self.allows_empty_collection);
1022        root = self.root_dismissals(root, &frame, overlay_active && !show_list);
1023        // The popup anchors to the Input's field-row bounds — not to the
1024        // label-to-value wrapper root — the way RAC's `triggerRef` reads
1025        // `groupRef.current || inputRef.current`. The field joins ahead of
1026        // the panel below so the positioner reads settled trigger bounds.
1027        root = root.child(input_group);
1028        let blur_focus = frame.blur_focus.clone();
1029        if show_list {
1030            root = root.child(self.popover(frame, cx));
1031        }
1032
1033        // The popover hangs off the field it belongs to, so the field — not
1034        // the panel — is the root's first child; the deferred panel still
1035        // paints over the value row below it.
1036        root = util::apply_sx(root, &self.sx);
1037        root.when_some(value_content, |root, value| root.child(value))
1038            .track_focus(&blur_focus)
1039    }
1040}
1041
1042impl ComboBox {
1043    /// Resolves the frame's keyed handles, matches, cursor and shared actions
1044    /// on top of the controlled state.
1045    fn frame(&self, controlled: ComboControlled, window: &mut Window, cx: &mut App) -> ComboFrame {
1046        let ComboControlled {
1047            ref base_id,
1048            ref items,
1049            open_state,
1050            ref open_own,
1051            ..
1052        } = controlled;
1053        // The field positioner discovers flips during prepaint. Keep its
1054        // resolved physical side keyed to this ComboBox so entry motion starts
1055        // from the side that is actually painted.
1056        let (resolved_placement, entry_placement) =
1057            crate::popover::field_placement_feedback(window, cx, base_id, self.placement);
1058
1059        // Owned copies: `input.render` below needs `cx` mutably.
1060        let anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>> = window
1061            .use_keyed_state(element_id::scoped(base_id, "anchor-bounds"), cx, |_, _| {
1062                Rc::new(Cell::new(None))
1063            })
1064            .read(cx)
1065            .clone();
1066        let colors = cx.colors().clone();
1067        let layout = cx.layout().clone();
1068        // One binding feeds every panel surface below: the painted panel and
1069        // the entry zoom that animates it. The trigger's own box is the held
1070        // field's, and the shared field chrome paints that one.
1071        let container_radius = self.radius.unwrap_or_else(|| util::container_radius(cx));
1072        let close_open = util::shared({
1073            let own = open_own.clone();
1074            let callback = self.on_open_change.clone();
1075            move |window: &mut Window, cx: &mut App| {
1076                let is_open = own.as_ref().map_or(open_state, |held| *held.read(cx));
1077                if !is_open {
1078                    return;
1079                }
1080                if let Some(held) = &own {
1081                    held.update(cx, |value, cx| {
1082                        *value = false;
1083                        cx.notify();
1084                    });
1085                }
1086                if let Some(callback) = &callback {
1087                    callback(&false, window, cx);
1088                }
1089            }
1090        });
1091        let raw_query = self.state.read(cx).value().to_owned();
1092        let is_invalid = self.is_invalid || self.error_message.is_some();
1093        let focus_handle = self.state.read(cx).focus_handle.clone();
1094
1095        self.sync_form(&controlled, &raw_query, &focus_handle, window, cx);
1096
1097        let show_all_items =
1098            window.use_keyed_state(element_id::scoped(base_id, "show-all-items"), cx, |_, _| {
1099                false
1100            });
1101        let last_query =
1102            window.use_keyed_state(element_id::scoped(base_id, "last-query"), cx, |_, _| {
1103                raw_query.clone()
1104            });
1105        if *last_query.read(cx) != raw_query {
1106            last_query.update(cx, |value, _| value.clone_from(&raw_query));
1107            show_all_items.update(cx, |value, _| *value = false);
1108        }
1109        let display_full_collection =
1110            *show_all_items.read(cx) || (self.menu_trigger == MenuTrigger::Manual && !open_state);
1111
1112        // Which suggestion the keyboard is on. Input edits clear it, matching
1113        // React Stately's focused-key reset before the filtered list changes.
1114        // Created ahead of the matches: the idle gate below reads it.
1115        let cursor = window.use_keyed_state(element_id::scoped(base_id, "cursor"), cx, |_, _| {
1116            None::<ComboCursor>
1117        });
1118
1119        let matches = self.matches(
1120            &controlled,
1121            &raw_query,
1122            display_full_collection,
1123            &cursor,
1124            window,
1125            cx,
1126        );
1127        let focus_open = self.focus_open(&controlled, &show_all_items, window, cx);
1128
1129        // Whether the pointer went down inside the input-plus-panel subtree.
1130        // The panel's outside-press listener reads only the panel bounds, so
1131        // the input is geometrically outside it even though both belong to the
1132        // same ComboBox. Capture the whole subtree for one dispatch: input
1133        // presses keep the list open, and the chevron or a row owns its click.
1134        let inside_pressed = Rc::new(Cell::new(false));
1135
1136        let stale_cursor = cursor.read(cx).as_ref().is_some_and(|focused| {
1137            let visible = cursor_position(&matches, focused).is_some();
1138            let retained_hidden = focused.hidden_query.as_deref() == Some(raw_query.as_str())
1139                && items.iter().any(|item| item.key() == &focused.key);
1140            self.disabled_keys.contains(&focused.key) || (!visible && !retained_hidden)
1141        });
1142        if stale_cursor {
1143            cursor.update(cx, |value, _| *value = None);
1144        }
1145        let cursor_at = cursor
1146            .read(cx)
1147            .as_ref()
1148            .and_then(|focused| cursor_position(&matches, focused));
1149        // React Aria keeps the focused row in view; the panel scrolls and the
1150        // virtual list scrolls itself. `use_keyed_state` takes `cx` mutably.
1151        let list_scroll =
1152            window.use_keyed_state(element_id::scoped(base_id, "list-scroll"), cx, |_, _| {
1153                crate::VirtualListHandle::uniform(0)
1154            });
1155        let panel_scroll =
1156            window.use_keyed_state(element_id::scoped(base_id, "panel-scroll"), cx, |_, _| {
1157                gpui::ScrollHandle::new()
1158            });
1159        let list_scroll_now = list_scroll.read(cx).clone();
1160        let panel_scroll_now = panel_scroll.read(cx).clone();
1161
1162        let commit_value = self.commit_action(&controlled);
1163        let blur_close = close_open.clone();
1164        let blur_commit = commit_value.clone();
1165        let blur_scope =
1166            util::on_focus_leave(window, cx, base_id, !self.is_disabled, move |window, cx| {
1167                blur_commit(window, cx);
1168                blur_close(window, cx);
1169            });
1170        let blur_focus = blur_scope.focus_handle();
1171
1172        let ComboControlled {
1173            entity_id,
1174            base_id,
1175            items,
1176            multiple,
1177            selection_own,
1178            open_state,
1179            open_own,
1180            overlay_phase,
1181            dismissal_token,
1182            ..
1183        } = controlled;
1184        ComboFrame {
1185            entity_id,
1186            base_id,
1187            items,
1188            multiple,
1189            selection_own,
1190            open_state,
1191            open_own,
1192            overlay_phase,
1193            dismissal_token,
1194            resolved_placement,
1195            entry_placement,
1196            anchor_bounds,
1197            colors,
1198            layout,
1199            container_radius,
1200            close_open,
1201            raw_query,
1202            is_invalid,
1203            focus_handle,
1204            show_all_items,
1205            display_full_collection,
1206            cursor,
1207            matches,
1208            focus_open,
1209            inside_pressed,
1210            cursor_at,
1211            list_scroll_now,
1212            panel_scroll_now,
1213            commit_value,
1214            blur_scope,
1215            blur_focus,
1216        }
1217    }
1218
1219    /// Mirrors the value into the live form state, applies a controlled
1220    /// `selected_key`, and installs the reset that restores the default.
1221    fn sync_form(
1222        &self,
1223        controlled: &ComboControlled,
1224        raw_query: &str,
1225        focus_handle: &gpui::FocusHandle,
1226        window: &mut Window,
1227        cx: &mut App,
1228    ) {
1229        let ComboControlled {
1230            ref base_id,
1231            ref items,
1232            ref default_selection,
1233            ref selection_own,
1234            ..
1235        } = *controlled;
1236        // The live form value follows the pinned `formValue` serialization:
1237        // the selected key(s) by default, the typed text when
1238        // `allowsCustomValue` forces text mode. Success and validity are the
1239        // inner input's own mirrors (`live_text` reads them there), so only
1240        // the value and the focus handle — the handle a blocked submit must
1241        // reach — live in this shared state.
1242        let form_text = form_submits_text(self.form_value, self.allows_custom_value);
1243        {
1244            let mut form = self.form_state.borrow_mut();
1245            form.value = if form_text {
1246                crate::form::FormValue::Text(SharedString::from(raw_query))
1247            } else {
1248                crate::form::FormValue::Keys(self.selected_keys.clone())
1249            };
1250            form.focus = Some(focus_handle.clone());
1251        }
1252
1253        // `selected_key`'s controlled sync: the owner hands the same key to
1254        // the builder every render, so the label may move into the input only
1255        // when that key actually changes — writing it every frame would
1256        // clobber the text being typed. The empty string is v3's `null` and
1257        // clears the input; a key with no item resolves to no label.
1258        if self.selected_key_sync {
1259            let applied_key =
1260                window.use_keyed_state(element_id::scoped(base_id, "applied-key"), cx, |_, _| {
1261                    None::<SharedString>
1262                });
1263            let owned_key = self.selected_keys.first().cloned().unwrap_or_default();
1264            let first_apply = applied_key.read(cx).is_none();
1265            if applied_key.read(cx).clone() != Some(owned_key.clone()) {
1266                // Pinned `getDefaultInputValue` derives the text from the
1267                // selected key only when no `defaultInputValue` was given, so
1268                // the first application must leave the seeded text in place —
1269                // `value` seeds the same slot and is owed the same leave-alone;
1270                // later key changes still move their labels in.
1271                if !(first_apply && (self.default_input_value.is_some() || self.value.is_some())) {
1272                    let label = label_of_key(items, &owned_key).cloned().unwrap_or_default();
1273                    self.state.update(cx, |state, cx| {
1274                        state.set_value(label.to_string());
1275                        cx.notify();
1276                    });
1277                }
1278                applied_key.update(cx, |v, _| *v = Some(owned_key));
1279            }
1280        }
1281
1282        // A reset restores the default selection and the label it resolves
1283        // to, the way pinned react-stately resets `selectedKey` and lets
1284        // `resetInputValue` re-derive the text — reporting the restored text
1285        // through `onInputChange` when it actually changed, as the pinned
1286        // controlled input state does.
1287        let restore_own = selection_own.clone();
1288        let restore_state = Rc::downgrade(&self.form_state);
1289        let restore_input = self.state.clone();
1290        let restore_items = items.clone();
1291        let restore_default = default_selection.clone();
1292        let restore_input_change = self.on_input_change.clone();
1293        let restore_all = self.on_selection_change_all.clone();
1294        self.form_state.borrow_mut().restore = (restore_own.is_some() || restore_all.is_some())
1295            .then(|| {
1296                util::shared(move |window: &mut Window, cx: &mut App| {
1297                    let default_text = restore_default
1298                        .first()
1299                        .and_then(|key| label_of_key(&restore_items, key))
1300                        .cloned()
1301                        .unwrap_or_default();
1302                    if let Some(state) = restore_state.upgrade() {
1303                        state.borrow_mut().value =
1304                            crate::form::FormValue::Keys(restore_default.clone());
1305                    }
1306                    let input_changed = restore_input.read(cx).value() != default_text.as_ref();
1307                    restore_input.update(cx, |state, cx| {
1308                        state.set_value(default_text.to_string());
1309                        cx.notify();
1310                    });
1311                    if input_changed {
1312                        if let Some(cb) = &restore_input_change {
1313                            cb(&default_text, window, cx);
1314                        }
1315                    }
1316                    if let Some(held) = &restore_own {
1317                        let keys = restore_default.clone();
1318                        held.update(cx, |v, cx| {
1319                            *v = keys;
1320                            cx.notify();
1321                        });
1322                    }
1323                    if let Some(cb) = &restore_all {
1324                        cb(&restore_default, window, cx);
1325                    }
1326                }) as Arc<dyn Fn(&mut Window, &mut App)>
1327            });
1328    }
1329
1330    /// The rows the list draws this frame: cached, retained for the exit
1331    /// frame, or skipped entirely while nothing consumes them.
1332    fn matches(
1333        &self,
1334        controlled: &ComboControlled,
1335        raw_query: &str,
1336        display_full_collection: bool,
1337        cursor: &Entity<Option<ComboCursor>>,
1338        window: &mut Window,
1339        cx: &mut App,
1340    ) -> Rc<[PickerItem]> {
1341        let ComboControlled {
1342            ref base_id,
1343            ref items,
1344            overlay_phase,
1345            ..
1346        } = *controlled;
1347        let overlay_active = overlay_phase != util::OverlayPhase::Closed;
1348        // Closed and idle frames draw no rows, so they skip the match work
1349        // entirely; a consuming filtered frame shares one cached list until
1350        // the query, collection or cap changes. Full-collection and
1351        // empty-query frames copy their capped prefix directly: no per-row
1352        // matching runs for them, so the cache's element-by-element key
1353        // comparison would cost more than the work it saves. A custom
1354        // `defaultFilter` owns the whole filtering decision, including an
1355        // empty query, and its configuration cannot join a cache key — its
1356        // results are never cached and it only runs while the matches are
1357        // consumed.
1358        let matches_cache =
1359            window.use_keyed_state(element_id::scoped(base_id, "matches"), cx, |_, _| {
1360                MatchesCache::default()
1361            });
1362        // Keep the last open collection for the retained exit frame. Closing
1363        // a row can update the input text in the same dispatch; React Aria
1364        // keeps the old rows under its fade and does not re-run a custom
1365        // filter for that committed label.
1366        let retained_matches = window.use_keyed_state(
1367            element_id::scoped(base_id, "retained-matches"),
1368            cx,
1369            |_, _| empty_matches(),
1370        );
1371        let consume_matches = overlay_active
1372            || cursor.read(cx).is_some()
1373            || (self.allows_custom_value && !raw_query.is_empty());
1374        let matches: Rc<[PickerItem]> = if overlay_phase == util::OverlayPhase::Exiting {
1375            retained_matches.read(cx).clone()
1376        } else if !consume_matches {
1377            empty_matches()
1378        } else {
1379            let filter = self.filter.clone();
1380            match &filter {
1381                Some(f) => Rc::from(compute_matches(
1382                    items,
1383                    raw_query,
1384                    self.max_items,
1385                    display_full_collection,
1386                    Some(f),
1387                )),
1388                None if display_full_collection => Rc::from(compute_matches(
1389                    items,
1390                    raw_query,
1391                    self.max_items,
1392                    true,
1393                    None,
1394                )),
1395                None if raw_query.is_empty() => Rc::from(compute_matches(
1396                    items,
1397                    raw_query,
1398                    self.max_items,
1399                    false,
1400                    None,
1401                )),
1402                None => matches_cache.update(cx, |cache, _| {
1403                    cache.get(items.clone(), raw_query, self.max_items, |items| {
1404                        compute_matches(items, raw_query, self.max_items, false, None)
1405                    })
1406                }),
1407            }
1408        };
1409        if overlay_phase == util::OverlayPhase::Open {
1410            retained_matches.update(cx, |value, _| *value = matches.clone());
1411        }
1412        matches
1413    }
1414
1415    /// `MenuTrigger::Focus`'s one-shot open on the field taking focus.
1416    fn focus_open(
1417        &self,
1418        controlled: &ComboControlled,
1419        show_all_items: &Entity<bool>,
1420        window: &mut Window,
1421        cx: &mut App,
1422    ) -> Option<Entity<FocusOpen>> {
1423        let ComboControlled {
1424            ref base_id,
1425            ref items,
1426            open_state,
1427            ref open_own,
1428            ..
1429        } = *controlled;
1430        // `MenuTrigger::Focus` opens the list when the field takes focus. The
1431        // check reads the focus handle every frame; the keyed state is the
1432        // one-shot that stops the panel from reopening behind a dismissal
1433        // (Escape, a pick, the chevron, a press outside) while the field keeps
1434        // the focus -- the frame after closing would otherwise re-answer the
1435        // still-held focus. The focus leaving resets it, so the *next* focus
1436        // session opens again.
1437        let focus_open =
1438            if self.menu_trigger == MenuTrigger::Focus && !self.is_disabled && !self.is_read_only {
1439                Some(window.use_keyed_state(
1440                    element_id::scoped(base_id, "focus-open"),
1441                    cx,
1442                    |_, _| FocusOpen {
1443                        can_open: true,
1444                        was_open: false,
1445                    },
1446                ))
1447            } else {
1448                None
1449            };
1450        if let Some(focus_open) = &focus_open {
1451            let focused = self.state.read(cx).focus_handle.is_focused(window);
1452            let mut held = *focus_open.read(cx);
1453            let mut now_open = open_state;
1454            if focused && !open_state && held.was_open {
1455                // The list closed while the field still has the focus: the
1456                // user dismissed it, so it must not come back.
1457                held.can_open = false;
1458            } else if focused && !open_state && held.can_open {
1459                // A fresh focus session: the field taking focus is the
1460                // gesture, when there is something to show -- the panel's own
1461                // gate of a non-empty result or an allowed empty state.
1462                let can_show = !items.is_empty() || self.allows_empty_collection;
1463                if can_show {
1464                    show_all_items.update(cx, |v, _| *v = true);
1465                    // Opening from focus writes both halves, the way the
1466                    // chevron's handler does: the uncontrolled flag, and the
1467                    // report to a controlled caller.
1468                    if let Some(open) = &open_own {
1469                        open.update(cx, |v, cx| {
1470                            *v = true;
1471                            cx.notify();
1472                        });
1473                    }
1474                    if let Some(cb) = &self.on_open_change {
1475                        cb(&true, window, cx);
1476                    }
1477                    held.can_open = false;
1478                    now_open = true;
1479                }
1480            }
1481            if !focused {
1482                // The focus left; the next focus session may open again.
1483                held.can_open = true;
1484            }
1485            held.was_open = now_open;
1486            focus_open.update(cx, |v, _| *v = held);
1487        }
1488        focus_open
1489    }
1490
1491    /// The commit a focus loss or an outside press runs.
1492    fn commit_action(&self, controlled: &ComboControlled) -> ComboAction {
1493        let ComboControlled {
1494            ref items,
1495            multiple,
1496            ref selection_own,
1497            ..
1498        } = *controlled;
1499        // ComboBox commits its text/selection when focus leaves even while the
1500        // list is closed. This is `useComboBoxState.setFocused(false)`, not the
1501        // generic popover close used by Select-family controls.
1502        util::shared({
1503            let state = self.state.clone();
1504            let selection_own = selection_own.clone();
1505            let selected = self.selected_keys.clone();
1506            let items = items.clone();
1507            let selection_change = self.on_selection_change_all.clone();
1508            let input_change = self.on_input_change.clone();
1509            let allows_custom = self.allows_custom_value;
1510            move |window: &mut Window, cx: &mut App| {
1511                let current = state.read(cx).value().to_owned();
1512                let selected_text = selected
1513                    .first()
1514                    .and_then(|key| label_of_key(&items, key))
1515                    .cloned()
1516                    .unwrap_or_default();
1517
1518                if allows_custom {
1519                    // Pinned react-stately's commit path: text that no longer
1520                    // matches the selected item's label is a custom value,
1521                    // which carries a null selected key.
1522                    if !multiple && current != selected_text && !selected.is_empty() {
1523                        if let Some(held) = &selection_own {
1524                            held.update(cx, |value, cx| {
1525                                value.clear();
1526                                cx.notify();
1527                            });
1528                        }
1529                        if let Some(callback) = &selection_change {
1530                            callback(&[], window, cx);
1531                        }
1532                    }
1533                    return;
1534                }
1535
1536                let committed = if multiple {
1537                    String::new()
1538                } else {
1539                    selected_text.to_string()
1540                };
1541                if current != committed {
1542                    state.update(cx, |state, cx| {
1543                        state.set_value(committed.clone());
1544                        cx.notify();
1545                    });
1546                    if let Some(callback) = &input_change {
1547                        callback(&committed, window, cx);
1548                    }
1549                }
1550            }
1551        })
1552    }
1553
1554    /// The `.combo-box__trigger` chevron button.
1555    fn trigger(
1556        &self,
1557        frame: &ComboFrame,
1558        window: &mut Window,
1559        cx: &mut App,
1560    ) -> gpui::Stateful<gpui::Div> {
1561        let ComboFrame {
1562            ref base_id,
1563            open_state,
1564            ref open_own,
1565            ref colors,
1566            ref close_open,
1567            ref focus_handle,
1568            ref show_all_items,
1569            ref focus_open,
1570            ..
1571        } = *frame;
1572        let on_open_change = self.on_open_change.clone();
1573        let is_open = open_state;
1574        // `.combo-box__trigger` is `border-none bg-transparent
1575        // text-field-placeholder`, and its hover recolours the text to
1576        // `text-field-foreground`, filling nothing. `svg()` paints from its own
1577        // style rather than the inherited text color, so the hover refinement
1578        // goes on the glyph as well as the trigger that wraps it.
1579        let trigger_hover_fg = colors.field.foreground;
1580        let trigger_indicator = crate::anim::rotating_indicator_with_duration(
1581            &element_id::scoped(base_id, "trigger-indicator"),
1582            is_open,
1583            gpui::svg()
1584                .size(util::FIELD_ICON)
1585                .path(icons::CHEVRON_DOWN)
1586                .text_color(colors.muted)
1587                .when(!self.is_disabled && !self.is_read_only, |glyph| {
1588                    glyph.hover(move |st| st.text_color(trigger_hover_fg))
1589                }),
1590            150,
1591            window,
1592            cx,
1593        );
1594        let mut trigger = div()
1595            .id(element_id::scoped(base_id, "trigger"))
1596            // `combo-box/combo-box.js` composes an RAC `Button` here, whose
1597            // props come from `useComboBox`'s `buttonProps` — i.e.
1598            // `useMenuTrigger({type: 'listbox'})`, so
1599            // `.../overlays/useOverlayTrigger.mjs` gives it
1600            // `'aria-haspopup': 'listbox'`, `'aria-expanded': isOpen` and
1601            // `'aria-controls'`. Only the expansion flag ports; the other two
1602            // are recorded omissions in `crate::a11y`.
1603            .a11y(a11y::Role::Button)
1604            .a11y_expanded(is_open)
1605            .flex()
1606            .items_center()
1607            .justify_center()
1608            .flex_shrink_0()
1609            .size(px(20.))
1610            .rounded(px(6.))
1611            .text_color(colors.muted)
1612            .child(trigger_indicator);
1613        if !self.is_disabled && !self.is_read_only {
1614            trigger = trigger
1615                .cursor(util::interactive_cursor(cx))
1616                .hover(move |s| s.text_color(trigger_hover_fg));
1617            if on_open_change.is_some() || open_own.is_some() {
1618                let own = open_own.clone();
1619                let focus_open = focus_open.clone();
1620                let show_all_items = show_all_items.clone();
1621                let close = close_open.clone();
1622                let focus_handle = focus_handle.clone();
1623                trigger = trigger
1624                    // The chevron is a separate React Aria button. Focus the
1625                    // input on press start, but do not let its down bubble into
1626                    // the input row and also trigger the default focus-open path.
1627                    .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1628                        if let Some(focus_open) = &focus_open {
1629                            focus_open.update(cx, |v, _| v.can_open = false);
1630                        }
1631                        window.focus(&focus_handle, cx);
1632                        cx.stop_propagation();
1633                    })
1634                    .on_click(move |_, window, cx| {
1635                        if is_open {
1636                            close(window, cx);
1637                            return;
1638                        }
1639                        show_all_items.update(cx, |v, _| *v = true);
1640                        if let Some(held) = &own {
1641                            held.update(cx, |v, cx| {
1642                                *v = true;
1643                                cx.notify();
1644                            });
1645                        }
1646                        if let Some(cb) = &on_open_change {
1647                            cb(&true, window, cx);
1648                        }
1649                    });
1650            }
1651        }
1652        trigger
1653    }
1654
1655    /// The held `Input` with the chevron in its end slot and the edit
1656    /// handler that opens, filters and closes the list.
1657    fn input(&self, frame: &ComboFrame, trigger: gpui::Stateful<gpui::Div>) -> Input {
1658        let ComboFrame {
1659            ref items,
1660            ref selection_own,
1661            open_state,
1662            ref open_own,
1663            ref close_open,
1664            ref raw_query,
1665            is_invalid,
1666            ref show_all_items,
1667            ref cursor,
1668            ref focus_open,
1669            ..
1670        } = *frame;
1671        let cursor_on_change = cursor.clone();
1672        let validate = self.validate.clone();
1673        let mut input = Input::new(self.state.clone())
1674            .variant(self.variant)
1675            .is_disabled(self.is_disabled)
1676            .is_invalid(is_invalid)
1677            .is_required(self.is_required)
1678            .is_read_only(self.is_read_only)
1679            .auto_focus(self.auto_focus)
1680            .when_some(self.font_family.clone(), |i, family| i.font_family(family))
1681            .when_some(self.validation_behavior, |i, b| i.validation_behavior(b))
1682            .when_some(validate, |i, f| i.validate(move |v| f(v)))
1683            .end_content(trigger);
1684        input = input.with_field_box(self.field);
1685        // Edits open Focus and Input triggers only when there is something to
1686        // show, and close an already-open filtered collection when it empties.
1687        // Manual stays closed until its trigger opens it, then edits switch it
1688        // from the full collection to filtered rows.
1689        let input_change = self.on_input_change.clone();
1690        let open_own_on_change = open_own.clone();
1691        let open_change_cb = self.on_open_change.clone();
1692        let focus_open_on_change = focus_open.clone();
1693        let show_all_items_on_change = show_all_items.clone();
1694        let items_on_change = items.clone();
1695        let filter_on_change = self.filter.clone();
1696        let menu_trigger_on_change = self.menu_trigger;
1697        let allows_empty_collection = self.allows_empty_collection;
1698        let was_open = open_state;
1699        let selection_own_on_change = selection_own.clone();
1700        let selected_on_change = self.selected_keys.clone();
1701        let selection_mode_on_change = self.selection_mode;
1702        let selection_change_on_input = self.on_selection_change_all.clone();
1703        let input_value_on_change = raw_query.clone();
1704        let close_on_empty = close_open.clone();
1705        input = input.on_change(move |text, window, cx| {
1706            if let Some(cb) = &input_change {
1707                cb(text, window, cx);
1708            }
1709            cursor_on_change.update(cx, |v, _| *v = None);
1710
1711            if selection_mode_on_change == SelectionMode::Single
1712                && !input_value_on_change.is_empty()
1713                && text.is_empty()
1714                && !selected_on_change.is_empty()
1715            {
1716                if let Some(held) = &selection_own_on_change {
1717                    held.update(cx, |value, cx| {
1718                        value.clear();
1719                        cx.notify();
1720                    });
1721                }
1722                if let Some(cb) = &selection_change_on_input {
1723                    cb(&[], window, cx);
1724                }
1725            }
1726
1727            let is_open = open_own_on_change
1728                .as_ref()
1729                .map_or(was_open, |held| *held.read(cx));
1730
1731            // A row pick closes the uncontrolled popup before copying the
1732            // selected label into the input. React Aria batches those updates
1733            // and does not run the custom filter for the value that is being
1734            // committed into a now-closing field. Preserve that boundary so
1735            // the retained exit frame stays visual-only and the filter's
1736            // callback count does not change on a pick.
1737            let closing_after_pick = was_open && !is_open;
1738            let has_matches = if closing_after_pick {
1739                true
1740            } else {
1741                match &filter_on_change {
1742                    Some(f) => items_on_change.iter().any(|item| f(item.label(), text)),
1743                    None if text.is_empty() => !items_on_change.is_empty(),
1744                    None => {
1745                        let query = text.to_lowercase();
1746                        items_on_change
1747                            .iter()
1748                            .any(|item| item.label().to_lowercase().contains(&query))
1749                    }
1750                }
1751            };
1752            let can_show = has_matches || allows_empty_collection;
1753
1754            if !is_open && menu_trigger_on_change != MenuTrigger::Manual && can_show {
1755                if let Some(focus_open) = &focus_open_on_change {
1756                    focus_open.update(cx, |v, _| v.can_open = false);
1757                }
1758                if let Some(held) = &open_own_on_change {
1759                    held.update(cx, |v, cx| {
1760                        *v = true;
1761                        cx.notify();
1762                    });
1763                }
1764                if let Some(cb) = &open_change_cb {
1765                    cb(&true, window, cx);
1766                }
1767            } else if is_open && !can_show {
1768                close_on_empty(window, cx);
1769            }
1770
1771            show_all_items_on_change.update(cx, |v, cx| {
1772                *v = false;
1773                cx.notify();
1774            });
1775            if menu_trigger_on_change == MenuTrigger::Manual {
1776                cx.refresh_windows();
1777            }
1778        });
1779
1780        if self.full_width {
1781            input = input.full_width();
1782        }
1783        if let Some(label) = self.label.clone() {
1784            input = input.label(label);
1785        }
1786        if let Some(placeholder) = self.placeholder.clone() {
1787            input = input.placeholder(placeholder);
1788        }
1789        if is_invalid {
1790            if let Some(message) = self.error_message.clone() {
1791                input = input.error_message(message);
1792            }
1793        } else if let Some(description) = self.description.clone() {
1794            input = input.description(description);
1795        }
1796        input
1797    }
1798
1799    /// `ComboBox.Value`, drawn below the field once something is chosen.
1800    fn value_row(&mut self, frame: &ComboFrame) -> Option<gpui::AnyElement> {
1801        let ComboFrame {
1802            entity_id,
1803            ref items,
1804            ref colors,
1805            ..
1806        } = *frame;
1807        // `ComboBox.Value` — `.combo-box__value` is `text-sm
1808        // text-field-foreground empty:hidden`, so it shows only once something
1809        // is chosen. The selection is read in its own order — pinned
1810        // react-stately 3.49.0's `selectedKeys` is a JS `Set`, which iterates
1811        // in insertion order — and each key resolves to its item wherever that
1812        // item now sits; a key with no item renders nothing.
1813        let mut value_content = None;
1814        if let Some(render) = self.value_content.take() {
1815            let mut labels: Vec<SharedString> = Vec::new();
1816            let mut indices: Vec<usize> = Vec::new();
1817            for key in &self.selected_keys {
1818                if let Some((index, item)) =
1819                    items.iter().enumerate().find(|(_, it)| it.key() == key)
1820                {
1821                    labels.push(item.label().clone());
1822                    indices.push(index);
1823                }
1824            }
1825            let text = labels
1826                .iter()
1827                .map(ToString::to_string)
1828                .collect::<Vec<_>>()
1829                .join(", ");
1830            let value_selector = format!("combobox-value-{entity_id}");
1831            let default_children = div()
1832                .debug_selector(move || value_selector)
1833                .w_full()
1834                .min_w_0()
1835                .whitespace_normal()
1836                .text_size(util::FIELD_TEXT)
1837                .line_height(px(20.))
1838                .text_color(colors.field.foreground)
1839                .child(text.clone())
1840                .into_any_element();
1841            value_content = Some(render(util::SelectionValue {
1842                selected_items: &labels,
1843                selected_indices: &indices,
1844                selected_keys: Some(&self.selected_keys),
1845                selected_text: &text,
1846                is_placeholder: labels.is_empty(),
1847                default_children,
1848            }));
1849        }
1850        value_content
1851    }
1852
1853    /// The root's key handler: the list walk, Enter's commit or revert, and
1854    /// `allowsCustomValue`'s custom commit.
1855    fn root_keys(&self, root: gpui::Div, frame: &ComboFrame) -> gpui::Div {
1856        let ComboFrame {
1857            ref items,
1858            multiple,
1859            ref selection_own,
1860            open_state,
1861            ref open_own,
1862            ref close_open,
1863            ref raw_query,
1864            ref show_all_items,
1865            display_full_collection,
1866            ref cursor,
1867            ref matches,
1868            ref list_scroll_now,
1869            ref panel_scroll_now,
1870            ref blur_scope,
1871            ..
1872        } = *frame;
1873        let key_rows: Rc<[PickerItem]> =
1874            if open_state || (self.allows_custom_value && !raw_query.is_empty()) {
1875                Rc::clone(matches)
1876            } else {
1877                items
1878                    .iter()
1879                    .take(self.max_items)
1880                    .cloned()
1881                    .collect::<Vec<_>>()
1882                    .into()
1883            };
1884        let stops: Vec<usize> = (0..key_rows.len())
1885            .filter(|i| {
1886                key_rows
1887                    .get(*i)
1888                    .is_some_and(|item| !self.disabled_keys.contains(item.key()))
1889            })
1890            .collect();
1891        let held = cursor.clone();
1892        let wrap = self.should_focus_wrap;
1893        let virtual_rows = self.row_height.is_some();
1894        let key_list_scroll = list_scroll_now.clone();
1895        let key_panel_scroll = panel_scroll_now.clone();
1896        let rows = key_rows;
1897        let state = self.state.clone();
1898        let allows_custom_value = self.allows_custom_value;
1899        let was_open = open_state;
1900        let show_all_items = show_all_items.clone();
1901        let on_selection_change = self.on_selection_change.clone();
1902        let on_selection_change_all = self.on_selection_change_all.clone();
1903        let on_input_change = self.on_input_change.clone();
1904        let selected_now = self.selected_keys.clone();
1905        let key_query = raw_query.clone();
1906        let key_display_full = display_full_collection;
1907        let key_items = items.clone();
1908        let key_filter = self.filter.clone();
1909        let key_max_items = self.max_items;
1910        let key_disabled = self.disabled_keys.clone();
1911        let key_menu_trigger = self.menu_trigger;
1912        let open_own_keys = open_own.clone();
1913        let on_open_change = self.on_open_change.clone();
1914        let key_selection_own = selection_own.clone();
1915        let key_close = close_open.clone();
1916        let key_blur = blur_scope.clone();
1917        let key_multiple = multiple;
1918        let handler = ComboKeys {
1919            rows,
1920            stops,
1921            held,
1922            wrap,
1923            virtual_rows,
1924            key_list_scroll,
1925            key_panel_scroll,
1926            state,
1927            allows_custom_value,
1928            was_open,
1929            show_all_items,
1930            on_selection_change,
1931            on_selection_change_all,
1932            on_input_change,
1933            selected_now,
1934            key_query,
1935            key_display_full,
1936            key_items,
1937            key_filter,
1938            key_max_items,
1939            key_disabled,
1940            key_menu_trigger,
1941            open_own_keys,
1942            on_open_change,
1943            key_selection_own,
1944            key_close,
1945            key_blur,
1946            key_multiple,
1947        };
1948        root.on_key_down(move |event, window, cx| handler.on_key_down(event, window, cx))
1949    }
1950
1951    /// Escape on the root, and the outside press when no list is drawn.
1952    fn root_dismissals(
1953        &self,
1954        mut root: gpui::Div,
1955        frame: &ComboFrame,
1956        dismiss_without_list: bool,
1957    ) -> gpui::Div {
1958        let ComboFrame {
1959            ref dismissal_token,
1960            ref close_open,
1961            ref commit_value,
1962            ref blur_scope,
1963            ..
1964        } = *frame;
1965        let escape_close = close_open.clone();
1966        root =
1967            util::dismiss_on_escape_with_token(root, dismissal_token.clone(), move |window, cx| {
1968                escape_close(window, cx);
1969                util::DismissResult::Handled
1970            });
1971        if dismiss_without_list {
1972            let dismiss_close = close_open.clone();
1973            let dismiss_commit = commit_value.clone();
1974            let dismiss_blur = blur_scope.clone();
1975            root = util::dismiss_on_press_outside_with_token(
1976                root,
1977                dismissal_token.clone(),
1978                move |window, cx| {
1979                    dismiss_blur.consume(cx);
1980                    dismiss_commit(window, cx);
1981                    dismiss_close(window, cx);
1982                    util::DismissResult::Handled
1983                },
1984            );
1985        }
1986        root
1987    }
1988
1989    /// The popover's scrolling `ListBox` surface: `bg-overlay`, the panel
1990    /// radius, the dark-mode hairline and the overlay shadow.
1991    fn panel_surface(&self, frame: &ComboFrame) -> gpui::Stateful<gpui::Div> {
1992        let ComboFrame {
1993            entity_id,
1994            ref base_id,
1995            overlay_phase,
1996            ref colors,
1997            ref layout,
1998            container_radius,
1999            ref panel_scroll_now,
2000            ..
2001        } = *frame;
2002        let panel_selector = format!("combobox-panel-{entity_id}");
2003        div()
2004                .id(element_id::scoped(base_id, "panel"))
2005                // `useComboBox` hands `listBoxProps` to the popover's RAC
2006                // `ListBox`, which is `useListBox`'s literal `role: 'listbox'`
2007                // with `'aria-orientation'` defaulting to vertical. The rows
2008                // are this panel's children, so the panel is that list.
2009                .a11y(a11y::Role::ListBox)
2010                .a11y_orientation(herogpui_core::Orientation::Vertical)
2011                .debug_selector(move || panel_selector)
2012                .w_full()
2013                .flex()
2014                .flex_col()
2015                .gap(px(2.))
2016                .p(px(4.))
2017                .overflow_y_scroll()
2018                // `overscroll-contain` upstream: a wheel over the popup must
2019                // not scroll the page behind it (Select/ColorPicker pattern).
2020                // A retained exit panel is visual-only and must not block the
2021                // next pointer target behind its old bounds.
2022                .when(overlay_phase == util::OverlayPhase::Open, |el| el.occlude())
2023                .track_scroll(panel_scroll_now)
2024                // RAC caps the popover at the available viewport height
2025                // (`calculatePosition`'s `getMaxHeight`); the positioner
2026                // below re-lays the panel out with that cap, so the panel
2027                // carries a viewport-relative bound rather than a fixed one.
2028                .max_h_full()
2029                .rounded(container_radius)
2030                .bg(colors.overlay.background)
2031                // v3 gives a floating panel no border: `.popover` and friends are
2032                // `bg-overlay shadow-overlay` and a radius, and dark mode's
2033                // inset hairline is what separates the panel from the page.
2034                .when_some(layout.overlay_hairline, |el, hairline| {
2035                el.border(layout.border_width).border_color(hairline)
2036                })
2037                .text_color(colors.overlay.foreground)
2038                .when(
2039                    !layout.overlay_shadow.is_empty(),
2040                    |e: gpui::Stateful<gpui::Div>| e.shadow(layout.overlay_shadow.clone()),
2041                )
2042    }
2043
2044    /// The suggestion popover: surface, dismissal, empty state, rows and motion.
2045    fn popover(&mut self, frame: ComboFrame, cx: &mut App) -> gpui::Deferred {
2046        let panel = self.panel_surface(&frame);
2047        let ComboFrame {
2048            entity_id,
2049            base_id,
2050            multiple,
2051            selection_own,
2052            open_state,
2053            open_own,
2054            overlay_phase,
2055            dismissal_token,
2056            resolved_placement,
2057            entry_placement,
2058            anchor_bounds,
2059            colors,
2060            layout,
2061            container_radius,
2062            close_open,
2063            cursor,
2064            matches,
2065            inside_pressed,
2066            cursor_at,
2067            list_scroll_now,
2068            commit_value,
2069            blur_scope,
2070            ..
2071        } = frame;
2072        // React Aria dismisses the list on a press outside it; Escape is
2073        // read in the field's own key handler above. A press that started
2074        // in the input-plus-panel subtree is not outside the ComboBox;
2075        // the input keeps the list open, while the chevron and rows own
2076        // their respective clicks.
2077        let dismiss_close = close_open.clone();
2078        let dismiss_commit = commit_value.clone();
2079        let dismiss_blur = blur_scope;
2080        let mut panel =
2081            util::dismiss_on_press_outside_with_token(panel, dismissal_token, move |window, cx| {
2082                if inside_pressed.get() {
2083                    return util::DismissResult::Declined;
2084                }
2085                dismiss_blur.consume(cx);
2086                dismiss_commit(window, cx);
2087                dismiss_close(window, cx);
2088                util::DismissResult::Handled
2089            });
2090
2091        if matches.is_empty() {
2092            // `allowsCustomValue` means an unmatched query is still valid,
2093            // so the empty state has to say something different.
2094            let message = if self.allows_custom_value {
2095                "Press Enter to use this value"
2096            } else {
2097                "No matching options"
2098            };
2099            panel = panel.child(
2100                div()
2101                    .px(px(8.))
2102                    .py(px(6.))
2103                    .text_size(util::FIELD_TEXT)
2104                    .line_height(px(20.))
2105                    .text_color(colors.muted)
2106                    .child(message),
2107            );
2108        }
2109
2110        // Everything a row reads, owned: the virtual list's row callback is
2111        // `'static` and runs again on every scroll, so it cannot borrow
2112        // `self` or the theme -- and one row builder for both paths is what
2113        // keeps a virtual list drawing the same row as a short one.
2114        let matches_len = matches.len();
2115        let rows = matches;
2116        let sections = self.sections.clone();
2117        let row_disabled_keys = self.disabled_keys.clone();
2118        let row_read_only = self.is_read_only;
2119        let row_selected_keys = self.selected_keys.clone();
2120        let indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>> =
2121            self.indicator.take().map(Rc::from);
2122        let on_change_all = self.on_selection_change_all.clone();
2123        let row_input_change = self.on_input_change.clone();
2124        let on_change_one = self.on_selection_change.clone();
2125        let row_state = self.state.clone();
2126        let row_cursor = cursor;
2127        let selection_own = selection_own;
2128        let row_open_own = open_own;
2129        let row_open_state = open_state;
2130        let row_close_open = close_open.clone();
2131        let row_disabled_opacity = layout.disabled_opacity;
2132        // The rows' shared parent id, owned by the `'static` row builder:
2133        // each row adds its item key as its own segment below it.
2134        let row_base = element_id::scoped(&base_id, "item");
2135        // `useOption` adds `aria-posinset`/`aria-setsize` only
2136        // `if (isVirtualized)`; `row_height` is what windows this list.
2137        let row_virtualized = self.row_height.is_some();
2138        let panel_interactive = overlay_phase == util::OverlayPhase::Open;
2139        let row_count = rows.len();
2140        let row_padding_x = self.row_padding_x.unwrap_or(px(8.));
2141        let row_font_family = self.row_font_family.clone();
2142        let row_padding_y = self.row_padding_y.unwrap_or(px(6.));
2143        let rows = ComboRows {
2144            entity_id,
2145            multiple,
2146            cursor_at,
2147            colors,
2148            row_hover_bg: self.row_hover_bg,
2149            rows,
2150            sections,
2151            row_disabled_keys,
2152            row_read_only,
2153            row_selected_keys,
2154            indicator,
2155            on_change_all,
2156            row_input_change,
2157            on_change_one,
2158            row_state,
2159            row_cursor,
2160            selection_own,
2161            row_open_own,
2162            row_open_state,
2163            row_close_open,
2164            row_disabled_opacity,
2165            row_base,
2166            row_virtualized,
2167            panel_interactive,
2168            row_count,
2169            row_padding_x,
2170            row_font_family,
2171            row_padding_y,
2172        };
2173
2174        match self.row_height {
2175            // Virtual: only the rows in view are built, which is what makes
2176            // a thousand options affordable. The list itself is the scroll
2177            // container: `Infer` sizes it from its rows — the full
2178            // natural height on the positioner's measure pass (so the
2179            // flip sees the real extent, like upstream's `overlaySize`),
2180            // capped to the available height on the capped pass — while
2181            // the ListBox stays `overflow-clip`, as in v3. A fixed inner
2182            // height plus an outer scroller would nest two scroll
2183            // containers and strand rows between them.
2184            Some(row_height) => {
2185                // No `height`: a uniform `VirtualList` then sizes to its
2186                // rows (`ListSizingBehavior::Infer`).
2187                let handle = list_scroll_now;
2188                if handle.item_count() != matches_len {
2189                    handle.splice(0..handle.item_count(), matches_len);
2190                }
2191                panel = panel.child(crate::VirtualList::new(
2192                    element_id::scoped(&base_id, "rows"),
2193                    &handle,
2194                    move |i, _window, cx| rows.row(i, Some(row_height), cx),
2195                ));
2196            }
2197            None => {
2198                for index in 0..matches_len {
2199                    panel = panel.child(rows.row(index, None, cx));
2200                }
2201            }
2202        }
2203
2204        let (slide_x, slide_y) = crate::popover::placement_entry_offset(entry_placement);
2205        let zoom = crate::anim::ZoomBox::panel(px(4.), container_radius).padding_x(px(4.));
2206        let zoom = crate::anim::ZoomBox {
2207            slide_x: (slide_x != 0.0).then(|| px(slide_x)),
2208            slide_y: (slide_y != 0.0).then(|| px(slide_y)),
2209            ..zoom
2210        };
2211        let panel = if overlay_phase == util::OverlayPhase::Exiting {
2212            crate::anim::exiting(
2213                panel,
2214                element_id::scoped(&base_id, "anim-out"),
2215                zoom,
2216                crate::anim::Motion::LIST_OUT,
2217                cx,
2218            )
2219        } else {
2220            crate::anim::entering_zoom(
2221                panel,
2222                element_id::scoped(&base_id, "anim"),
2223                zoom,
2224                crate::anim::Motion::LIST_IN,
2225                cx,
2226            )
2227        };
2228        // RAC positions the popover against the field with an 8px gap,
2229        // flips it when the other side has more room, and caps it at the
2230        // available viewport height past a 12px inset.
2231        util::floating(
2232            crate::popover::scrollable_field_popover_with_resolved_placement(
2233                anchor_bounds,
2234                self.placement,
2235                Some(resolved_placement),
2236                panel,
2237            ),
2238        )
2239    }
2240}
2241
2242/// The root's key handler, holding everything it reads. The handler is
2243/// `'static`, so it owns copies of the frame's state rather than borrowing
2244/// the ComboBox.
2245struct ComboKeys {
2246    rows: Rc<[PickerItem]>,
2247    stops: Vec<usize>,
2248    held: Entity<Option<ComboCursor>>,
2249    wrap: bool,
2250    virtual_rows: bool,
2251    key_list_scroll: crate::VirtualListHandle,
2252    key_panel_scroll: gpui::ScrollHandle,
2253    state: Entity<InputState>,
2254    allows_custom_value: bool,
2255    was_open: bool,
2256    show_all_items: Entity<bool>,
2257    on_selection_change: Option<OnSelectionChange>,
2258    on_selection_change_all: Option<OnSelectionChangeAll>,
2259    on_input_change: Option<OnInputChange>,
2260    selected_now: Vec<SharedString>,
2261    key_query: String,
2262    key_display_full: bool,
2263    key_items: Rc<[PickerItem]>,
2264    key_filter: Option<ComboFilter>,
2265    key_max_items: usize,
2266    key_disabled: std::collections::HashSet<SharedString>,
2267    key_menu_trigger: MenuTrigger,
2268    open_own_keys: Option<Entity<bool>>,
2269    on_open_change: Option<OnOpenChange>,
2270    key_selection_own: Option<Entity<Vec<SharedString>>>,
2271    key_close: ComboAction,
2272    key_blur: util::FocusLeave,
2273    key_multiple: bool,
2274}
2275
2276impl ComboKeys {
2277    fn on_key_down(&self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App) {
2278        // An owned handle, so the pinned `cursor_position(&rows, ..)` reads
2279        // the collection the way the closure that held it did.
2280        let rows = Rc::clone(&self.rows);
2281        let Self {
2282            ref stops,
2283            ref held,
2284            wrap,
2285            ref state,
2286            allows_custom_value,
2287            was_open,
2288            ref show_all_items,
2289            ref on_selection_change_all,
2290            ref on_input_change,
2291            ref selected_now,
2292            ref key_query,
2293            key_display_full,
2294            ref key_items,
2295            ref key_filter,
2296            key_max_items,
2297            ref key_disabled,
2298            key_menu_trigger,
2299            ref open_own_keys,
2300            ref key_selection_own,
2301            ref key_close,
2302            key_multiple,
2303            ..
2304        } = *self;
2305        let key = event.keystroke.key.as_str();
2306        let is_open = open_own_keys
2307            .as_ref()
2308            .map_or(was_open, |held| *held.read(cx));
2309        let stale_cursor = held.read(cx).as_ref().is_some_and(|focused| {
2310            let current_query = state.read(cx).value().to_owned();
2311            let display_full = (current_query == *key_query && *show_all_items.read(cx))
2312                || (key_menu_trigger == MenuTrigger::Manual && !is_open);
2313            let current_rows: Rc<[PickerItem]> =
2314                if !(is_open || (allows_custom_value && !current_query.is_empty())) {
2315                    key_items
2316                        .iter()
2317                        .take(key_max_items)
2318                        .cloned()
2319                        .collect::<Vec<_>>()
2320                        .into()
2321                } else if current_query == *key_query
2322                    && is_open == was_open
2323                    && display_full == key_display_full
2324                {
2325                    Rc::clone(&rows)
2326                } else {
2327                    compute_matches(
2328                        key_items,
2329                        &current_query,
2330                        key_max_items,
2331                        display_full,
2332                        key_filter.as_ref(),
2333                    )
2334                    .into()
2335                };
2336            let visible = cursor_position(&current_rows, focused).is_some();
2337            let retained_hidden = focused.hidden_query.as_deref() == Some(current_query.as_str())
2338                && key_items.iter().any(|item| item.key() == &focused.key);
2339            key_disabled.contains(&focused.key) || (!visible && !retained_hidden)
2340        });
2341        if stale_cursor {
2342            held.update(cx, |value, _| *value = None);
2343        }
2344        // `allowsCustomValue` is the promise behind the drawn hint
2345        // "Press Enter to use this value". A no-match query has no
2346        // cursor row at all (an empty stop list makes `resolve` report
2347        // Ignore, so the Activate arm never runs). Pinned
2348        // react-stately's `commitCustomValue` keeps the typed text and
2349        // sets the value to null: the selection clears, and the slice
2350        // callback reports it only when a selection actually existed.
2351        // Text that still matches the selected item's label is not a
2352        // custom value at all — pinned `commitValue` re-runs
2353        // `commitSelection` there, so the selection stands and the
2354        // callbacks stay silent. The existing single-value commit
2355        // remains single-mode only; React Aria keeps multiple-mode
2356        // custom input independent from the selected items.
2357        if allows_custom_value
2358            && key == "enter"
2359            && !state.read(cx).value().is_empty()
2360            && held
2361                .read(cx)
2362                .as_ref()
2363                .is_none_or(|focused| stale_cursor || cursor_position(&rows, focused).is_none())
2364        {
2365            let selected_label = selected_now
2366                .first()
2367                .and_then(|item| label_of_key(key_items, item))
2368                .cloned()
2369                .unwrap_or_default();
2370            if !key_multiple && selected_label != state.read(cx).value() {
2371                let had_selection = !selected_now.is_empty();
2372                if let Some(held) = &key_selection_own {
2373                    held.update(cx, |v, cx| {
2374                        v.clear();
2375                        cx.notify();
2376                    });
2377                }
2378                if had_selection {
2379                    if let Some(cb) = &on_selection_change_all {
2380                        cb(&[], window, cx);
2381                    }
2382                }
2383            }
2384            held.update(cx, |v, _| *v = None);
2385            key_close(window, cx);
2386            // Pinned React Aria 3.51.0's `Enter` shortcut prevents
2387            // the default only while the menu is open
2388            // (`shouldPreventDefault = state.isOpen`): a commit on a
2389            // closed field — custom value or not — leaves Enter to
2390            // bubble into an enclosing form's implicit submission.
2391            if is_open {
2392                cx.stop_propagation();
2393            }
2394            return;
2395        }
2396        if key == "enter" && (stale_cursor || held.read(cx).is_none()) {
2397            let reset_value = if key_multiple {
2398                String::new()
2399            } else {
2400                selected_now
2401                    .first()
2402                    .and_then(|item| label_of_key(key_items, item))
2403                    .cloned()
2404                    .unwrap_or_default()
2405                    .to_string()
2406            };
2407            let input_changed = state.read(cx).value() != reset_value;
2408            state.update(cx, |value, cx| {
2409                value.set_value(reset_value.clone());
2410                cx.notify();
2411            });
2412            if input_changed {
2413                if let Some(cb) = &on_input_change {
2414                    cb(&reset_value, window, cx);
2415                }
2416            }
2417            key_close(window, cx);
2418            // Only an Enter the list acted on is kept from an
2419            // enclosing form: an open list reverted, or a typed
2420            // query discarded. A closed field with nothing to revert
2421            // answers Enter with nothing here, and the keystroke may
2422            // still bubble into the form's implicit submission.
2423            if is_open || stale_cursor || input_changed {
2424                cx.stop_propagation();
2425            }
2426            return;
2427        }
2428        let from = held
2429            .read(cx)
2430            .as_ref()
2431            .and_then(|focused| cursor_position(&rows, focused));
2432        let nav_key = if key == "tab" && is_open && held.read(cx).is_some() {
2433            "enter"
2434        } else {
2435            key
2436        };
2437        // Pinned React Aria 3.51.0 binds PageUp/PageDown through the
2438        // listbox's `useSelectableCollection`, which a closed field
2439        // never runs: the suggestion list is not mounted, so the page
2440        // keys must not open it and must not move a retained cursor.
2441        // Open, those handlers require `manager.focusedKey != null` --
2442        // a mouse-opened, selection-less ComboBox has a null cursor
2443        // and must answer nothing until an arrow establishes one.
2444        // With a cursor the list is non-scrollable -- HeroUI v3.2.4
2445        // puts the overflow scrolling on the Popover while the ListBox
2446        // element is `overflow-clip` -- so a page takes the enabled
2447        // end: `stops` already omits disabled rows, whatever the
2448        // list's length or scroll state.
2449        let page_move = match nav_key {
2450            "pagedown" if is_open && from.is_some() => stops.last().copied(),
2451            "pageup" if is_open && from.is_some() => stops.first().copied(),
2452            _ => None,
2453        }
2454        .filter(|next| Some(*next) != from);
2455        let next_move = page_move.map_or_else(
2456            || crate::list_nav::resolve(stops, from, nav_key, wrap),
2457            crate::list_nav::Move::To,
2458        );
2459        self.apply_move(next_move, key, window, cx);
2460    }
2461
2462    /// The open list's answer to a resolved move: walk the cursor (opening
2463    /// the list), or take the cursor row.
2464    fn apply_move(
2465        &self,
2466        next_move: crate::list_nav::Move,
2467        key: &str,
2468        window: &mut Window,
2469        cx: &mut App,
2470    ) {
2471        let Self {
2472            ref rows,
2473            ref held,
2474            virtual_rows,
2475            ref key_list_scroll,
2476            ref key_panel_scroll,
2477            ref state,
2478            was_open,
2479            ref show_all_items,
2480            ref on_selection_change,
2481            ref on_selection_change_all,
2482            ref on_input_change,
2483            ref selected_now,
2484            ref key_items,
2485            ref open_own_keys,
2486            ref on_open_change,
2487            ref key_selection_own,
2488            ref key_close,
2489            ref key_blur,
2490            key_multiple,
2491            ..
2492        } = *self;
2493        match next_move {
2494            crate::list_nav::Move::To(next) => {
2495                let next_cursor = Some(cursor_for(rows, next, None));
2496                held.update(cx, |v, cx| {
2497                    *v = next_cursor;
2498                    cx.notify();
2499                });
2500                if virtual_rows {
2501                    key_list_scroll.scroll_to_item(next, crate::VirtualListScroll::Center);
2502                } else {
2503                    key_panel_scroll.scroll_to_item(next);
2504                }
2505                // Walking the list opens it, which is what typing does.
2506                if let Some(held) = &open_own_keys {
2507                    held.update(cx, |v, cx| {
2508                        *v = true;
2509                        cx.notify();
2510                    });
2511                }
2512                if !was_open {
2513                    show_all_items.update(cx, |v, _| *v = true);
2514                    if let Some(cb) = &on_open_change {
2515                        cb(&true, window, cx);
2516                    }
2517                }
2518            }
2519            crate::list_nav::Move::Activate => {
2520                // The activation reads the held cursor's key, not the
2521                // rendered row: a cursor retained across a query reset
2522                // (the multiple-mode pick above) can sit on an item
2523                // the capped or filtered list no longer renders, and
2524                // Enter must still toggle it.
2525                let Some(item_key) = held.read(cx).as_ref().map(|focused| focused.key.clone())
2526                else {
2527                    return;
2528                };
2529                let item_label = label_of_key(key_items, &item_key)
2530                    .cloned()
2531                    .unwrap_or_default();
2532                // An Enter that picks a row is the list's, not an
2533                // enclosing form's. A Tab mapped here keeps its
2534                // native motion: only Enter is stopped.
2535                if key == "enter" {
2536                    cx.stop_propagation();
2537                }
2538                if key_multiple {
2539                    let had_query = !state.read(cx).value().is_empty();
2540                    state.update(cx, |st, cx| {
2541                        st.set_value(String::new());
2542                        cx.notify();
2543                    });
2544                    held.update(cx, |v, _| {
2545                        if let Some(focused) = v {
2546                            focused.hidden_query = Some(String::new());
2547                        }
2548                    });
2549                    let mut next = selected_now.clone();
2550                    toggle_key(&mut next, &item_key);
2551                    if let Some(held) = &key_selection_own {
2552                        let next = next.clone();
2553                        held.update(cx, |v, cx| {
2554                            *v = next;
2555                            cx.notify();
2556                        });
2557                    }
2558                    if let Some(cb) = &on_selection_change_all {
2559                        cb(&next, window, cx);
2560                    }
2561                    if had_query {
2562                        if let Some(cb) = &on_input_change {
2563                            cb("", window, cx);
2564                        }
2565                    }
2566                    return;
2567                }
2568                // Taking a suggestion fills the field with the row's
2569                // label and closes the list, the way a click does.
2570                // Close first so the retained exit frame does not
2571                // re-run the custom filter for the value we are about
2572                // to copy into the input.
2573                key_close(window, cx);
2574                state.update(cx, |st, cx| {
2575                    st.set_value(item_label.to_string());
2576                    cx.notify();
2577                });
2578                // Uncontrolled: record the pick in the selection too,
2579                // or `ComboBox.Value` would never see it.
2580                if let Some(held) = &key_selection_own {
2581                    let next = vec![item_key.clone()];
2582                    held.update(cx, |v, cx| {
2583                        *v = next;
2584                        cx.notify();
2585                    });
2586                }
2587                held.update(cx, |v, _| *v = None);
2588                if key == "tab" {
2589                    key_blur.consume(cx);
2590                }
2591                if selected_now.first() != Some(&item_key) {
2592                    if let Some(cb) = &on_selection_change_all {
2593                        cb(std::slice::from_ref(&item_key), window, cx);
2594                    }
2595                }
2596                if let Some(cb) = &on_selection_change {
2597                    cb(&item_key, window, cx);
2598                }
2599            }
2600            crate::list_nav::Move::Ignore => {}
2601        }
2602    }
2603}
2604
2605/// Everything a suggestion row reads, owned: the virtual list's row callback
2606/// is `'static` and runs again on every scroll, so it cannot borrow the
2607/// ComboBox or the theme -- and one row builder for both paths is what keeps
2608/// a virtual list drawing the same row as a short one.
2609struct ComboRows {
2610    entity_id: u64,
2611    multiple: bool,
2612    cursor_at: Option<usize>,
2613    colors: herogpui_theme::ThemeColors,
2614    row_hover_bg: Option<gpui::Hsla>,
2615    rows: Rc<[PickerItem]>,
2616    sections: Vec<(SharedString, SharedString)>,
2617    row_disabled_keys: std::collections::HashSet<SharedString>,
2618    row_read_only: bool,
2619    row_selected_keys: Vec<SharedString>,
2620    indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>>,
2621    on_change_all: Option<OnSelectionChangeAll>,
2622    row_input_change: Option<OnInputChange>,
2623    on_change_one: Option<OnSelectionChange>,
2624    row_state: Entity<InputState>,
2625    row_cursor: Entity<Option<ComboCursor>>,
2626    selection_own: Option<Entity<Vec<SharedString>>>,
2627    row_open_own: Option<Entity<bool>>,
2628    row_open_state: bool,
2629    row_close_open: ComboAction,
2630    row_disabled_opacity: f32,
2631    /// The rows' shared parent id: each row adds its item key as its own
2632    /// segment below it.
2633    row_base: gpui::ElementId,
2634    row_virtualized: bool,
2635    panel_interactive: bool,
2636    row_count: usize,
2637    row_padding_x: Pixels,
2638    row_font_family: Option<SharedString>,
2639    row_padding_y: Pixels,
2640}
2641
2642impl ComboRows {
2643    /// One suggestion row, with its section header when one precedes it.
2644    fn row(&self, index: usize, fixed_h: Option<Pixels>, cx: &mut App) -> gpui::AnyElement {
2645        let Self {
2646            entity_id,
2647            multiple,
2648            cursor_at,
2649            ref colors,
2650            ref rows,
2651            ref sections,
2652            ref row_disabled_keys,
2653            row_read_only,
2654            ref row_selected_keys,
2655            ref indicator,
2656            row_disabled_opacity,
2657            ref row_base,
2658            row_virtualized,
2659            row_count,
2660            row_padding_x,
2661            ref row_font_family,
2662            row_padding_y,
2663            ..
2664        } = *self;
2665        let row_muted = colors.muted;
2666        let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
2667        let row_focus = colors.focus;
2668        let row_accent = colors.accent.color;
2669        let item = &rows[index];
2670        // A section header rides above the row it introduces, so the two
2671        // are one element -- a virtual row is one slot tall.
2672        let mut head: Vec<gpui::AnyElement> = Vec::new();
2673        let done = |head: Vec<gpui::AnyElement>, row: gpui::AnyElement| {
2674            div()
2675                .flex()
2676                .flex_col()
2677                .when_some(fixed_h, |el, h| el.h(h).w_full())
2678                .children(head)
2679                .child(row)
2680                .into_any_element()
2681        };
2682        // `ListBox.Section`'s `Header`, above the item it introduces.
2683        if let Some((_, label)) = sections.iter().find(|(at, _)| at == item.key()) {
2684            head.push(
2685                div()
2686                    .px(px(8.))
2687                    .pt(px(6.))
2688                    .pb(px(4.))
2689                    .text_size(px(12.))
2690                    .line_height(px(16.))
2691                    .font_weight(gpui::FontWeight::MEDIUM)
2692                    .text_color(row_muted)
2693                    .child(label.to_string())
2694                    .into_any_element(),
2695            );
2696        }
2697        // The row's element id comes from the item's key, so two items
2698        // that share a label never share an interactive row.
2699        let item_disabled = row_disabled_keys.contains(item.key());
2700        let hover_bg = row_hover_bg;
2701        let row_selector = format!("combobox-{entity_id}-item-{}", item.key());
2702        let row_selected = row_selected_keys.contains(item.key());
2703        let has_indicator_slot = indicator.is_some() || (multiple && row_selected);
2704        let mut row = div()
2705                        .id(element_id::scoped(row_base, item.key().clone()))
2706                        // `useOption.mjs`: `role: 'option'` plus
2707                        // `'aria-selected'` whenever the list selects at all.
2708                        // The highlighted row is what `useComboBox` points its
2709                        // input's `aria-activedescendant` at; gpui states that
2710                        // relation on the descendant, so the row can say it
2711                        // even though this component does not own the input.
2712                        .a11y_named(
2713                            a11y::Role::ListBoxOption,
2714                            &a11y::Name::labelled(item.label().clone()),
2715                        )
2716                        .a11y_selected(row_selected_keys.contains(item.key()))
2717                        .when(cursor_at == Some(index), |row| row.a11y_active_descendant())
2718                        .when(row_virtualized, |row| {
2719                            row.a11y_set_position(index, row_count)
2720                        })
2721                        .debug_selector(move || row_selector)
2722                        // `.list-box-item`: `min-h-9 rounded-2xl px-2 py-1.5 gap-3`.
2723                        .min_h(util::FIELD_HEIGHT)
2724                        .px(row_padding_x)
2725                        .py(row_padding_y)
2726                        .gap(px(12.))
2727                        .rounded(util::soft_radius(cx))
2728                        .flex()
2729                        .items_center()
2730                        .justify_between()
2731                        .text_size(util::FIELD_TEXT)
2732                        .line_height(px(20.))
2733                        // HeroUI's list-box item reserves `pe-7` whenever its
2734                        // indicator slot is present; the indicator itself is
2735                        // absolute at the inline end. Keeping it out of flex
2736                        // flow prevents long labels from pushing the checkmark.
2737                        .relative()
2738                        .when(has_indicator_slot, |row| row.pr(px(28.)));
2739        if let Some(family) = row_font_family.clone() {
2740            row = row.font_family(family);
2741        }
2742
2743        if item_disabled {
2744            row = row.opacity(row_disabled_opacity);
2745        } else if !row_read_only {
2746            row = row
2747                .cursor(util::interactive_cursor(cx))
2748                .hover(move |s| s.bg(hover_bg));
2749        }
2750
2751        // `status-focused` on the row the keyboard is on.
2752        if util::shows_focus_ring(cursor_at == Some(index), cx) {
2753            row = row.border_2().border_color(row_focus);
2754        }
2755
2756        // HeroUI's ListBox.Item does not add an ellipsis rule. Keep
2757        // normal text flow in both natural and virtual rows; the
2758        // caller owns the fixed row geometry when `row_height` is
2759        // supplied, just as the upstream Virtualizer owns its
2760        // `rowHeight` layout.
2761        let label = div().flex_1().min_w_0().whitespace_normal();
2762        row = row.child(label.child(item.label().to_string()));
2763
2764        // `ListBox.ItemIndicator`: a caller-drawn tick replaces the
2765        // check glyph, and is asked for on every row so it can draw the
2766        // unselected state too.
2767        match &indicator {
2768            Some(render) => {
2769                row = row.child(
2770                    div()
2771                        .absolute()
2772                        .top_0()
2773                        .bottom_0()
2774                        .right(px(8.))
2775                        .w(px(16.))
2776                        .flex()
2777                        .items_center()
2778                        .justify_center()
2779                        .child(render(row_selected)),
2780                );
2781            }
2782            None if multiple && row_selected => {
2783                row = row.child(
2784                    div()
2785                        .absolute()
2786                        .top_0()
2787                        .bottom_0()
2788                        .right(px(8.))
2789                        .w(px(16.))
2790                        .flex()
2791                        .items_center()
2792                        .justify_center()
2793                        .child(
2794                            gpui::svg()
2795                                .size(px(13.))
2796                                .path(icons::CHECK)
2797                                .text_color(row_accent),
2798                        ),
2799                );
2800            }
2801            None => {}
2802        }
2803
2804        if item_disabled || row_read_only {
2805            return done(head, row.into_any_element());
2806        }
2807
2808        // Multiple mode toggles membership and leaves the panel open.
2809        if multiple {
2810            row = self.attach_toggle(row, index);
2811            return done(head, row.into_any_element());
2812        }
2813
2814        // Taking a suggestion fills the field with the row's label and
2815        // closes the list, the way a click does; the selection rides
2816        // the row's key.
2817        row = self.attach_pick(row, index);
2818
2819        done(head, row.into_any_element())
2820    }
2821
2822    /// A multiple-mode row's pointer toggle: the panel stays open.
2823    fn attach_toggle(
2824        &self,
2825        mut row: gpui::Stateful<gpui::Div>,
2826        index: usize,
2827    ) -> gpui::Stateful<gpui::Div> {
2828        let rows = Rc::clone(&self.rows);
2829        let item = &rows[index];
2830        let Self {
2831            panel_interactive,
2832            ref row_selected_keys,
2833            ref on_change_all,
2834            ref row_input_change,
2835            ref row_state,
2836            ref row_cursor,
2837            ref selection_own,
2838            ..
2839        } = *self;
2840        if panel_interactive && (on_change_all.is_some() || selection_own.is_some()) {
2841            let cb = on_change_all.clone();
2842            let own = selection_own.clone();
2843            let current = row_selected_keys.clone();
2844            let value = item.key().clone();
2845            let state = row_state.clone();
2846            let cursor = row_cursor.clone();
2847            let next_cursor = cursor_for(&rows, index, Some(String::new()));
2848            let input_change = row_input_change.clone();
2849            row = row.on_click(move |_, window, cx| {
2850                let had_query = !state.read(cx).value().is_empty();
2851                let focus_handle = state.read(cx).focus_handle.clone();
2852                focus_handle.focus(window, cx);
2853                state.update(cx, |st, cx| {
2854                    st.set_value(String::new());
2855                    cx.notify();
2856                });
2857                cursor.update(cx, |v, _| *v = Some(next_cursor.clone()));
2858                let mut next = current.clone();
2859                toggle_key(&mut next, &value);
2860                // Uncontrolled: keep the new set, or picking an
2861                // item would do nothing.
2862                if let Some(held) = &own {
2863                    let set = next.clone();
2864                    held.update(cx, |v, cx| {
2865                        *v = set;
2866                        cx.notify();
2867                    });
2868                }
2869                if let Some(cb) = &cb {
2870                    cb(&next, window, cx);
2871                }
2872                if had_query {
2873                    if let Some(cb) = &input_change {
2874                        cb("", window, cx);
2875                    }
2876                }
2877            });
2878        }
2879        row
2880    }
2881
2882    /// A single-mode row's pointer pick: fills the field and closes the list.
2883    fn attach_pick(
2884        &self,
2885        mut row: gpui::Stateful<gpui::Div>,
2886        index: usize,
2887    ) -> gpui::Stateful<gpui::Div> {
2888        let item = &self.rows[index];
2889        let Self {
2890            panel_interactive,
2891            ref row_selected_keys,
2892            ref on_change_all,
2893            ref on_change_one,
2894            ref row_state,
2895            ref selection_own,
2896            ref row_open_own,
2897            row_open_state,
2898            ref row_close_open,
2899            ..
2900        } = *self;
2901        let value = item.key().clone();
2902        let label = item.label().clone();
2903        let state = row_state.clone();
2904        let on_selection_change = on_change_one.clone();
2905        let on_selection_change_all = on_change_all.clone();
2906        let selection_changed = row_selected_keys.first() != Some(&value);
2907        let own = selection_own.clone();
2908        let open_own = row_open_own.clone();
2909        let close_open = row_close_open.clone();
2910        if panel_interactive {
2911            row = row.on_click(move |_, window, cx| {
2912                let is_open = open_own
2913                    .as_ref()
2914                    .map_or(row_open_state, |held| *held.read(cx));
2915                if !is_open {
2916                    return;
2917                }
2918                // Close first so the retained exit frame does not
2919                // re-run the custom filter for the selected label.
2920                close_open(window, cx);
2921                state.update(cx, |s, cx| {
2922                    s.set_value(label.to_string());
2923                    cx.notify();
2924                });
2925                // Uncontrolled: record the pick in the selection too, or
2926                // `ComboBox.Value` would never see it.
2927                if let Some(held) = &own {
2928                    let next = vec![value.clone()];
2929                    held.update(cx, |v, cx| {
2930                        *v = next;
2931                        cx.notify();
2932                    });
2933                }
2934                if selection_changed {
2935                    if let Some(cb) = &on_selection_change_all {
2936                        cb(std::slice::from_ref(&value), window, cx);
2937                    }
2938                }
2939                if let Some(cb) = &on_selection_change {
2940                    cb(&value, window, cx);
2941                }
2942            });
2943        }
2944        row
2945    }
2946}
2947
2948// The pinned `.combo-box__trigger` hovers `text-field-foreground` and stays
2949// `bg-transparent`; a filled hover looks plausible on screen, so the check is
2950// mechanical.
2951#[cfg(test)]
2952mod hover_tokens {
2953    #[test]
2954    fn the_trigger_hover_recolours_the_text_and_fills_nothing() {
2955        // Scan the implementation only; this test's own text names the
2956        // forbidden accessor.
2957        let source = include_str!("combo_box.rs")
2958            .split("#[cfg(test)]")
2959            .next()
2960            .expect("the implementation section is always present");
2961        assert!(
2962            source.contains(".hover(move |s| s.text_color(trigger_hover_fg))"),
2963            "the trigger hover must recolour the text to `text-field-foreground` \
2964             (pinned `.combo-box__trigger:hover`)"
2965        );
2966        assert!(
2967            !source.contains("let hover_bg = colors.default.hover();"),
2968            "the trigger hover must not fill a background \
2969             (pinned `.combo-box__trigger` is `bg-transparent`)"
2970        );
2971    }
2972}
2973
2974crate::util::impl_component_styled!(ComboBox);