Skip to main content

herogpui_components/
select.rs

1//! Select — port of `@heroui/select` with single and multiple selection.
2//!
3//! Pinned v3.2.4 / React Aria Components 1.20.0 keep a stable `Key` separate
4//! from each item's `textValue`: `value` / `defaultValue` / `selectedKeys` /
5//! `disabledKeys`, the selection callbacks and the form value address items by
6//! key, while typeahead and the visible text use the label. Items are
7//! therefore [`crate::PickerItem`]s, exactly as in
8//! [`crate::Autocomplete`] and [`crate::ComboBox`]: a label cannot serve as
9//! that key — two items may share one, and then they alias each other's
10//! selection, disabled state and row identity.
11//!
12//! The selection and the keyboard cursor are held as keys, so both follow an
13//! item across a re-render that reorders the collection. Reports and the form
14//! value walk the collection, so a selection reads in row order however it
15//! was picked — the walk `Select.Value` has always followed.
16
17use std::{
18    cell::{Cell, RefCell},
19    collections::HashSet,
20    rc::Rc,
21};
22
23use gpui::{
24    prelude::*, px, AnimationExt, App, IntoElement, ParentElement, Pixels, RenderOnce,
25    SharedString, StatefulInteractiveElement, Styled, Window,
26};
27use herogpui_core::{element_id, Color, FieldVariant, Placement, SelectionMode};
28use herogpui_theme::ActiveTheme;
29
30use crate::{
31    a11y::{self, A11y as _},
32    icons,
33    picker_item::PickerItem,
34    selection::toggle_key,
35    util,
36};
37
38type OnSelectionChange =
39    std::sync::Arc<dyn Fn(&Option<SharedString>, &mut Window, &mut App) + 'static>;
40
41// This port approximates pinned RAC's en-US `Intl.ListFormat` conjunction.
42fn format_selected_names(names: &[String]) -> String {
43    match names {
44        [] => String::new(),
45        [name] => name.clone(),
46        [first, second] => format!("{first} and {second}"),
47        _ => format!(
48            "{}, and {}",
49            names[..names.len() - 1].join(", "),
50            names.last().expect("the multiple-item branch is non-empty")
51        ),
52    }
53}
54
55/// A selection re-ordered to collection order: every chosen key that still
56/// resolves to an item, in row order. Select reports its selection through
57/// this walk — the trigger text, the plural callback, the form value and
58/// `Select.Value` all read the collection the way this does.
59fn in_collection_order(selected: &[SharedString], keys: &[SharedString]) -> Vec<SharedString> {
60    keys.iter()
61        .filter(|key| selected.contains(*key))
62        .cloned()
63        .collect()
64}
65
66/// The chosen keys that resolve to collection items, in row order — the
67/// single key and the multiple set are disjoint channels, so filtering on
68/// both is safe. Keys the collection no longer holds resolve to nothing, the
69/// way an out-of-range index always did.
70fn resolved_keys(
71    items: &[PickerItem],
72    single: &Option<SharedString>,
73    multiple: &[SharedString],
74) -> Vec<SharedString> {
75    items
76        .iter()
77        .map(|item| item.key())
78        .filter(|key| single.as_ref() == Some(*key) || multiple.contains(*key))
79        .cloned()
80        .collect()
81}
82
83/// Pinned React Stately 3.49.0 `useMultipleSelectionState`'s anchor record,
84/// on the option keys a Select's `stops` walk: where a Shift extension
85/// reaches from, how far the last one went, and whether the selection is the
86/// raw `all` a `selectAll` produced. It lives beside the cursor in keyed
87/// state, so it survives closing and reopening the popover the way the
88/// pinned hook survives a listbox remount.
89#[derive(Clone, Debug, Default)]
90struct SelectSelectionRange {
91    anchor: Option<SharedString>,
92    current: Option<SharedString>,
93    is_all: bool,
94}
95
96/// The selection after extending to `target` from `range`'s anchor.
97///
98/// The old anchor..current range is *replaced* by anchor..target, so extending
99/// backwards shrinks again; a raw `all` collapses to the new key; a first
100/// extension without an anchor selects from the target itself, which is what
101/// the pinned SelectionManager does when nothing anchors it yet. Only
102/// `selectable` keys enter the range, so disabled options are skipped, and
103/// the result walks the collection so it reports in row order.
104fn extend_selection_range(
105    current: &[SharedString],
106    collection: &[SharedString],
107    selectable: &[SharedString],
108    range: &SelectSelectionRange,
109    target: &SharedString,
110) -> Vec<SharedString> {
111    if range.is_all {
112        return vec![target.clone()];
113    }
114    let anchor = range.anchor.as_ref().unwrap_or(target);
115    let previous = range.current.as_ref().unwrap_or(target);
116    let anchor_at = collection.iter().position(|key| key == anchor);
117    let previous_at = collection.iter().position(|key| key == previous);
118    let target_at = collection.iter().position(|key| key == target);
119    let between = |from: Option<usize>, to: Option<usize>| {
120        from.zip(to)
121            .map(|(from, to)| if from <= to { from..=to } else { to..=from })
122    };
123    let mut next: Vec<SharedString> = current.to_vec();
124    if let Some(previous_range) = between(anchor_at, previous_at) {
125        for at in previous_range {
126            let stale = &collection[at];
127            if let Some(held) = next.iter().position(|key| key == stale) {
128                next.remove(held);
129            }
130        }
131    }
132    if let Some(target_range) = between(anchor_at, target_at) {
133        for at in target_range {
134            let key = &collection[at];
135            if selectable.contains(key) && !next.contains(key) {
136                next.push(key.clone());
137            }
138        }
139    }
140    in_collection_order(&next, collection)
141}
142
143/// Pinned React Aria 3.51.0 `useSelectableCollection` registers Home and End
144/// only for the chords each platform's handler admits: none, Shift, Alt, and
145/// Alt+Shift on macOS -- no Meta or Control handler exists -- and none,
146/// Shift, Control, and Control+Shift on Windows and Linux. The upstream
147/// matcher reads exactly the browser's canonical modifier flags -- Alt,
148/// Control, Meta, Shift -- so GPUI's `function` flag is ignored here: a
149/// browser exposes no Fn state for it to read, so vetoing on the flag would
150/// claim a pinned guard that does not exist, and the framework delivers an
151/// Fn-bearing press with every matched modifier flag still false. A chord
152/// outside the registration is entirely inert: no cursor move, no selection,
153/// no preventDefault. `macos` is simulated explicitly so every platform's
154/// unit tests can prove both maps.
155fn home_end_registered(modifiers: gpui::Modifiers, macos: bool) -> bool {
156    if macos {
157        !modifiers.control && !modifiers.platform
158    } else {
159        !modifiers.alt && !modifiers.platform
160    }
161}
162
163/// Pinned `useSelectableCollection` (`isCtrlKeyPressed`): a Shift move
164/// extends the range on the collection's navigation keys, while Home and
165/// End extend only from Control+Shift on Windows and Linux. macOS registers
166/// no Home/End extension at all -- its Shift and Alt+Shift chords move the
167/// cursor alone -- so the platform is an explicit bool rather than a `cfg!`.
168fn shift_home_end_extends(key_name: &str, control: bool, macos: bool) -> bool {
169    !matches!(key_name, "home" | "end") || (!macos && control)
170}
171
172/// The optional `Select.ClearButton` composition part.
173///
174/// Passed to [`Select::clear_button`]. It has no separate keyboard focus stop:
175/// Backspace and Delete on the closed trigger perform the same clear action.
176#[derive(Default)]
177pub struct SelectClearButton {
178    children: Vec<gpui::AnyElement>,
179    on_click: Option<std::sync::Arc<dyn Fn(&gpui::ClickEvent, &mut Window, &mut App)>>,
180}
181
182impl ParentElement for SelectClearButton {
183    fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
184        self.children.extend(elements);
185    }
186}
187
188impl SelectClearButton {
189    /// Creates a clear button.
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Replaces the default close icon with caller content.
195    pub fn child(mut self, child: impl IntoElement) -> Self {
196        self.children.push(child.into_any_element());
197        self
198    }
199
200    /// Called after the selection request and the root's `on_clear` callback.
201    pub fn on_click(
202        mut self,
203        callback: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
204    ) -> Self {
205        self.on_click = Some(std::sync::Arc::new(callback));
206        self
207    }
208}
209
210/// HeroUI Select (controlled).
211#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
212#[derive(IntoElement)]
213pub struct Select {
214    /// `name` — the name this control submits under; read back by
215    /// [`Self::form_field`].
216    name: Option<SharedString>,
217    id: gpui::ElementId,
218    items: Vec<PickerItem>,
219    selected: Option<SharedString>,
220    /// Whether `value` was supplied. `Option<SharedString>` cannot distinguish
221    /// "controlled, nothing selected" from "uncontrolled" on its own.
222    is_controlled: bool,
223    default_value: Option<SharedString>,
224    /// Backs `selectionMode="multiple"`; `selected` backs `single`.
225    selected_keys: Vec<SharedString>,
226    is_multiple_controlled: bool,
227    default_selected_keys: Vec<SharedString>,
228    selection_mode: SelectionMode,
229    /// `isOpen` — `None` leaves the component holding the flag, seeded from
230    /// `defaultOpen`.
231    is_open: Option<bool>,
232    default_open: bool,
233    placement: Placement,
234    label: Option<SharedString>,
235    placeholder: SharedString,
236    /// Whether `placeholder` came from the caller; otherwise render swaps
237    /// in the localized default (`i18n::UiString::SelectPlaceholder`).
238    placeholder_is_set: bool,
239    description: Option<SharedString>,
240    variant: FieldVariant,
241    variant_is_set: bool,
242    is_disabled: bool,
243    is_invalid: bool,
244    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
245    should_focus_wrap: bool,
246    /// `ListLayout`'s `rowHeight`, which virtualizes the popover list.
247    row_height: Option<Pixels>,
248    /// Replaces the list rows' `px-2.5` horizontal padding.
249    row_padding_x: Option<Pixels>,
250    trigger_text_size: Option<Pixels>,
251    row_text_size: Option<Pixels>,
252    panel_padding: Option<Pixels>,
253    /// Replaces the list rows' `py-1.5` vertical padding.
254    row_padding_y: Option<Pixels>,
255    /// The fill a hovered option row takes, in place of `--default`.
256    row_hover_bg: Option<gpui::Hsla>,
257    /// The trigger's hover endpoint, in place of the variant's hover token.
258    trigger_hover_bg: Option<gpui::Hsla>,
259    /// The family the option rows are drawn with; unset keeps the inherited
260    /// family. A detached popover does not inherit the trigger's font.
261    row_font_family: Option<SharedString>,
262    /// The trigger text's family; unset keeps the inherited family.
263    font_family: Option<SharedString>,
264    /// The corner radius of the detached panel, in place of the owning
265    /// `container_radius` helper.
266    radius: Option<Pixels>,
267    /// `ListBox.Section` — the heading that precedes an option, by item key.
268    sections: Vec<(SharedString, SharedString)>,
269    /// `ListBox.ItemIndicator` — draws the tick. The closure is handed whether
270    /// the row is selected.
271    indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
272    #[allow(clippy::type_complexity)]
273    item_leading: Option<Box<dyn Fn(&SharedString, bool) -> Option<gpui::AnyElement> + 'static>>,
274    /// `Select.Indicator` — draws the trigger indicator. The closure is handed
275    /// whether the popover is open; arbitrary caller content is kept as-is,
276    /// while the built-in SVG follows the pinned 150ms rotation.
277    trigger_indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
278    /// `Select.Value` — draws the trigger's value. The closure is handed the
279    /// selected key, or `None` while the placeholder shows.
280    value_content: Option<Box<dyn Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static>>,
281    is_required: bool,
282    disabled_keys: HashSet<SharedString>,
283    full_width: bool,
284    on_open_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
285    on_selection_change: Option<OnSelectionChange>,
286    clear_buttons: Vec<SelectClearButton>,
287    on_clear: Option<std::sync::Arc<dyn Fn(&mut Window, &mut App)>>,
288    on_selection_change_all:
289        Option<std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
290    /// Optional trigger geometry/chrome overrides; defaults are the stock box.
291    field: util::FieldBox,
292    /// Mirrors the current selection, validity, successful state, focus and
293    /// reset behavior for a live [`crate::form::FormField`].
294    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
295    /// The `sx` slot, refined over the root style at the end of render.
296    sx: Option<Box<gpui::StyleRefinement>>,
297    recipes: Vec<SharedString>,
298}
299
300impl Select {
301    /// Composes a clear control inside the trigger. Repeated calls add parts.
302    /// An empty selection hides the control while retaining its layout space.
303    pub fn clear_button(mut self, button: SelectClearButton) -> Self {
304        self.clear_buttons.push(button);
305        self
306    }
307
308    /// Reports a nonempty selection's clear request, including controlled mode.
309    pub fn on_clear(mut self, callback: impl Fn(&mut Window, &mut App) + 'static) -> Self {
310        self.on_clear = Some(std::sync::Arc::new(callback));
311        self
312    }
313
314    /// `onChange` — the v3 name for [`Select::on_selection_change`].
315    pub fn on_change(
316        self,
317        handler: impl Fn(&Option<SharedString>, &mut Window, &mut App) + 'static,
318    ) -> Self {
319        self.on_selection_change(handler)
320    }
321
322    /// `disabledKeys` — keys of the items that cannot be chosen. Disabled
323    /// state is per key, so one of two same-label items can be disabled
324    /// alone.
325    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
326        self.disabled_keys = keys.into_iter().collect();
327        self
328    }
329
330    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
331    /// `ListLayout`'s `rowHeight` -- and what virtualizes the popover list.
332    ///
333    /// v3 wraps the list in `<Virtualizer layout={ListLayout}>` inside
334    /// `Select.Popover`; gpui's `uniform_list` builds only the rows in view, and
335    /// it can do that because every row is this tall.
336    pub fn row_height(mut self, h: impl Into<Pixels>) -> Self {
337        self.row_height = Some(h.into());
338        self
339    }
340
341    /// Sets whether keyboard focus wraps at the ends of the list (v3 `shouldFocusWrap`).
342    pub fn should_focus_wrap(mut self, v: bool) -> Self {
343        self.should_focus_wrap = v;
344        self
345    }
346
347    /// `ListBox.Section` — a heading rendered above the item with this key.
348    pub fn section_before(
349        mut self,
350        item: impl Into<SharedString>,
351        label: impl Into<SharedString>,
352    ) -> Self {
353        self.sections.push((item.into(), label.into()));
354        self
355    }
356
357    /// `ListBox.ItemIndicator` — draw the selected tick yourself.
358    pub fn indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
359        self.indicator = Some(Box::new(render));
360        self
361    }
362
363    /// Draws a leading element on each option row, before its label.
364    ///
365    /// v3's `ListBox.Item` takes its children as a render function, so an
366    /// option is free to compose an avatar, an icon or a colour swatch beside
367    /// its text. [`crate::picker_item::PickerItem`] carries only a key and a
368    /// label — it is shared with the combo box, the autocomplete, the tag
369    /// filter and the swatch picker, and holds no element — so the per-row
370    /// element is supplied here instead, where the select already owns
371    /// [`Select::indicator`].
372    ///
373    /// The closure receives the row's key and whether it is selected, and
374    /// returns `None` for a row that takes no leading element. The element
375    /// sits in the row's normal `gap-3` flow, before the label, and the
376    /// trailing indicator slot is untouched.
377    ///
378    /// This draws the *rows*. A swatch on the closed trigger is
379    /// [`Select::value_content`], which is a separate slot upstream too.
380    pub fn item_leading(
381        mut self,
382        render: impl Fn(&SharedString, bool) -> Option<gpui::AnyElement> + 'static,
383    ) -> Self {
384        self.item_leading = Some(Box::new(render));
385        self
386    }
387
388    /// `Select.Indicator` — draw the trigger indicator yourself.
389    ///
390    /// The closure receives the current open state, which is the GPUI analog
391    /// of v3's `data-open` attribute. The default SVG indicator rotates over
392    /// the pinned 150ms transition; caller-provided content remains caller
393    /// owned and can use this state to render its own visual.
394    pub fn trigger_indicator(
395        mut self,
396        render: impl Fn(bool) -> gpui::AnyElement + 'static,
397    ) -> Self {
398        self.trigger_indicator = Some(Box::new(render));
399        self
400    }
401
402    /// `Select.Value` — draw the trigger's value yourself.
403    ///
404    /// The closure is handed the render props v3 passes into
405    /// `<Select.Value>{({defaultChildren, isPlaceholder, selectedItems}) => …}`,
406    /// so a `multiple` select can draw all of them and a caller can fall back to
407    /// what the trigger would have drawn.
408    pub fn value_content(
409        mut self,
410        render: impl Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static,
411    ) -> Self {
412        self.value_content = Some(Box::new(render));
413        self
414    }
415
416    /// Sets the invalid state (v3 `isInvalid`).
417    pub fn is_invalid(mut self, v: bool) -> Self {
418        self.is_invalid = v;
419        self
420    }
421
422    /// Sets the required state (v3 `isRequired`).
423    pub fn is_required(mut self, v: bool) -> Self {
424        self.is_required = v;
425        self
426    }
427
428    /// `fullWidth` — stretch to the container width.
429    pub fn full_width(mut self, v: bool) -> Self {
430        self.full_width = v;
431        self
432    }
433
434    /// Fixes the trigger box at `h`. Unset keeps the 36px `min-h-9`; content
435    /// taller than an explicit height overflows the box.
436    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
437        self.field.height = Some(h.into());
438        self
439    }
440
441    /// Replaces the trigger's `px-3` horizontal padding.
442    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
443        self.field.padding_x = Some(p.into());
444        self
445    }
446
447    /// Adds vertical padding to the trigger, in place of v3's `py-2`.
448    ///
449    /// The trigger is a `min-h` box, so this changes nothing for a value that
450    /// fits on one line: `px(8.)` on the stock 20px line advance resolves to
451    /// the same 36px the minimum already imposes. A value that wraps to two
452    /// lines then grows the trigger the way upstream's `py-2` does, instead of
453    /// keeping the 36px floor and pressing the two lines against its edges.
454    ///
455    /// Unset leaves the trigger unpadded, which is the pre-0.10.0 geometry.
456    /// It is the select that carries this rather than the whole field family:
457    /// the other members hold a single-line editor, whose box
458    /// [`Select::height`]'s counterparts already name outright.
459    pub fn padding_y(mut self, p: impl Into<Pixels>) -> Self {
460        self.field.padding_y = Some(p.into());
461        self
462    }
463
464    /// Renders the trigger with no background, border, field shadow, ring,
465    /// focus fill or hover fill, for a caller painting around it. The list
466    /// still opens and selects.
467    pub fn is_bare(mut self, v: bool) -> Self {
468        self.field.is_bare = v;
469        self.field.is_bare_is_set = true;
470        self
471    }
472
473    /// Shows or hides only the trigger's visual focus ring. The trigger stays
474    /// keyboard focusable and the list still opens when set to `false`.
475    pub fn focus_ring(mut self, v: bool) -> Self {
476        self.field.focus_ring = Some(v);
477        self
478    }
479
480    /// Overrides the trigger text size in pixels; unset preserves the stock metric.
481    pub fn trigger_text_size(mut self, value: impl Into<Pixels>) -> Self {
482        self.trigger_text_size = Some(value.into());
483        self
484    }
485
486    /// Overrides the row text size in pixels; unset preserves the stock metric.
487    pub fn row_text_size(mut self, value: impl Into<Pixels>) -> Self {
488        self.row_text_size = Some(value.into());
489        self
490    }
491
492    /// Overrides the panel padding in pixels; unset preserves the stock metric.
493    pub fn panel_padding(mut self, value: impl Into<Pixels>) -> Self {
494        self.panel_padding = Some(value.into());
495        self
496    }
497
498    /// Replaces the list rows' `px-2.5` horizontal padding.
499    pub fn row_padding_x(mut self, p: impl Into<Pixels>) -> Self {
500        self.row_padding_x = Some(p.into());
501        self
502    }
503
504    /// Replaces the list rows' `py-1.5` vertical padding.
505    pub fn row_padding_y(mut self, p: impl Into<Pixels>) -> Self {
506        self.row_padding_y = Some(p.into());
507        self
508    }
509
510    /// The trigger's fill while hovered, in place of `--field-hover`
511    /// (`--default-hover` on the secondary variant). The 150ms ease-smooth
512    /// fade, the border hover and the clear button's suppression are
513    /// unchanged; a focused, invalid, disabled or bare trigger does not hover,
514    /// so the override never reaches those states. Not a v3 prop: v3 tints the
515    /// trigger with a class.
516    pub fn trigger_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
517        self.trigger_hover_bg = Some(color.into());
518        self
519    }
520
521    /// The fill a hovered option row takes, in place of `--default`. v3 tints
522    /// the row with a class; this names the colour.
523    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
524        self.row_hover_bg = Some(color.into());
525        self
526    }
527
528    /// The family the trigger's value and placeholder are drawn with; unset
529    /// keeps the inherited family. The detached rows take
530    /// [`Select::row_font_family`] instead. Not a v3 prop; v3 sets it with a
531    /// class.
532    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
533        self.font_family = Some(family.into());
534        self
535    }
536
537    /// The family the option rows are drawn with; unset keeps the inherited
538    /// family. A detached popover does not inherit the trigger's font.
539    pub fn row_font_family(mut self, family: impl Into<SharedString>) -> Self {
540        self.row_font_family = Some(family.into());
541        self
542    }
543
544    /// The corner radius of the detached panel, in place of the owning
545    /// `container_radius` helper. The panel's entry zoom interpolates the same
546    /// value, so both follow the override. Not a v3 prop; the removed v2
547    /// `radius` prop is prohibited and this is a per-component repository
548    /// extension.
549    ///
550    /// The trigger is a field box of its own, painted by the shared field
551    /// chrome — `--field-radius`, not this value.
552    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
553        self.radius = Some(radius.into());
554        self
555    }
556
557    /// The one slot for caller-owned low-level styling: GPUI's styling methods
558    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
559    /// applied to the select's root element after every value the variant and
560    /// the active theme chose, so they win. The trigger paints its own chrome,
561    /// so this reaches the box that chrome sits in, not the chrome.
562    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
563        util::refine_sx(&mut self.sx, style);
564        self
565    }
566
567    /// Creates a select from an element id and its items.
568    pub fn new(id: impl Into<gpui::ElementId>, items: Vec<PickerItem>) -> Self {
569        Self {
570            name: None,
571            id: id.into(),
572            items,
573            selected: None,
574            is_controlled: false,
575            default_value: None,
576            selected_keys: Vec::new(),
577            is_multiple_controlled: false,
578            default_selected_keys: Vec::new(),
579            selection_mode: SelectionMode::Single,
580            is_open: None,
581            default_open: false,
582            placement: Placement::BottomStart,
583            label: None,
584            placeholder: "Select an item".into(),
585            placeholder_is_set: false,
586            description: None,
587            variant: FieldVariant::Primary,
588            variant_is_set: false,
589            is_disabled: false,
590            is_invalid: false,
591            should_focus_wrap: false,
592            row_height: None,
593            row_padding_x: None,
594            trigger_text_size: None,
595            row_text_size: None,
596            panel_padding: None,
597            row_padding_y: None,
598            row_hover_bg: None,
599            trigger_hover_bg: None,
600            row_font_family: None,
601            font_family: None,
602            item_leading: None,
603            radius: None,
604            field: util::FieldBox::default(),
605            sections: Vec::new(),
606            indicator: None,
607            trigger_indicator: None,
608            value_content: None,
609            is_required: false,
610            disabled_keys: HashSet::new(),
611            full_width: false,
612            on_open_change: None,
613            on_selection_change: None,
614            clear_buttons: Vec::new(),
615            on_clear: None,
616            on_selection_change_all: None,
617            form_state: live_form_state(),
618            sx: None,
619            recipes: Vec::new(),
620        }
621    }
622
623    /// `name` — the name this control submits under.
624    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
625        self.name = Some(name.into());
626        self
627    }
628
629    /// The `Form` field this control submits, when it has a `name`.
630    ///
631    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
632    /// its ancestor, so the control hands the pair over instead. Borrows, so the
633    /// control is still yours to place:
634    ///
635    /// ```
636    /// # use gpui::{prelude::*, Window};
637    /// # use herogpui_components::{Form, PickerItem, Select};
638    /// # struct Demo;
639    /// # impl Render for Demo {
640    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
641    /// #         let form = Form::new();
642    /// #         let control =
643    /// #             Select::new("city", vec![PickerItem::new("kyiv", "Kyiv")]).name("city");
644    /// let field = control.form_field();
645    /// form.field(field.unwrap()).child(control)
646    /// #     }
647    /// # }
648    /// # let mut tcx = gpui::TestAppContext::single();
649    /// # tcx.update(herogpui_theme::ThemeProvider::init);
650    /// # let _ = tcx.add_window_view(|_, _| Demo);
651    /// ```
652    pub fn form_field(&self) -> Option<crate::form::FormField> {
653        let name = self.name.clone()?;
654        let selected = if self.is_controlled {
655            self.selected.clone()
656        } else {
657            self.default_value.clone()
658        };
659        let selected_keys = if self.is_multiple_controlled {
660            self.selected_keys.clone()
661        } else {
662            self.default_selected_keys.clone()
663        };
664        sync_select_form(
665            &self.form_state,
666            select_form_value(&self.items, &selected, &selected_keys),
667            self.is_invalid,
668            !self.is_disabled,
669        );
670        Some(
671            crate::form::FormField::live(name, self.form_state.clone())
672                .is_required(self.is_required),
673        )
674    }
675
676    /// `selectionMode`
677    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
678        self.selection_mode = mode;
679        self
680    }
681
682    /// The chosen item keys under `selectionMode="multiple"` — `selectedKeys`,
683    /// the `ListBox` spelling of [`Select::value`]'s controlled set.
684    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
685        self.selected_keys = keys.into_iter().collect();
686        self.is_multiple_controlled = true;
687        self
688    }
689
690    /// `defaultSelectedKeys` under `selectionMode="multiple"` — the
691    /// uncontrolled initial selection.
692    pub fn default_selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
693        self.default_selected_keys = keys.into_iter().collect();
694        self
695    }
696
697    /// Reports the whole selection, for `selectionMode="multiple"`.
698    pub fn on_selection_change_all(
699        mut self,
700        handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
701    ) -> Self {
702        self.on_selection_change_all = Some(std::sync::Arc::new(handler));
703        self
704    }
705
706    /// `value` — the selected item, by key. Supplying it makes the select
707    /// controlled, even with `None`.
708    pub fn value(mut self, key: Option<SharedString>) -> Self {
709        self.selected = key;
710        self.is_controlled = true;
711        self
712    }
713
714    /// `defaultValue` — the uncontrolled initial selection, by key.
715    ///
716    /// Only consulted when `value` is not supplied; the select then owns the
717    /// selection and choosing an option moves it.
718    pub fn default_value(mut self, key: Option<SharedString>) -> Self {
719        self.default_value = key;
720        self
721    }
722
723    /// `placement` on the popover.
724    pub fn placement(mut self, placement: Placement) -> Self {
725        self.placement = placement;
726        self
727    }
728
729    /// Sets the controlled open state (v3 `isOpen`).
730    pub fn is_open(mut self, v: bool) -> Self {
731        self.is_open = Some(v);
732        self
733    }
734    /// `defaultOpen` — the uncontrolled initial state.
735    ///
736    /// Only consulted when `is_open` is not supplied; the component then owns
737    /// the flag and its trigger toggles it.
738    pub fn default_open(mut self, v: bool) -> Self {
739        self.default_open = v;
740        self
741    }
742
743    /// Sets the label.
744    pub fn label(mut self, l: impl Into<SharedString>) -> Self {
745        self.label = Some(l.into());
746        self
747    }
748
749    /// Sets the placeholder text.
750    pub fn placeholder(mut self, p: impl Into<SharedString>) -> Self {
751        self.placeholder = p.into();
752        self.placeholder_is_set = true;
753        self
754    }
755
756    /// Sets the description.
757    pub fn description(mut self, d: impl Into<SharedString>) -> Self {
758        self.description = Some(d.into());
759        self
760    }
761
762    /// Sets the field variant (v3 `variant`).
763    pub fn variant(mut self, v: FieldVariant) -> Self {
764        self.variant = v;
765        self.variant_is_set = true;
766        self
767    }
768
769    /// Named theme overlay from [`herogpui_theme::ComponentThemes::select`].
770    /// Stackable; a missing name adds no override.
771    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
772        self.recipes.push(name.into());
773        self
774    }
775
776    /// Sets the disabled state (v3 `isDisabled`).
777    pub fn is_disabled(mut self, v: bool) -> Self {
778        self.is_disabled = v;
779        self
780    }
781
782    /// Sets the handler called when the open state changes (v3 `onOpenChange`).
783    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
784        self.on_open_change = Some(std::sync::Arc::new(f));
785        self
786    }
787
788    /// Sets the handler called when the selection changes (v3 `onSelectionChange`).
789    pub fn on_selection_change(
790        mut self,
791        f: impl Fn(&Option<SharedString>, &mut Window, &mut App) + 'static,
792    ) -> Self {
793        self.on_selection_change = Some(std::sync::Arc::new(f));
794        self
795    }
796}
797
798impl Select {
799    fn value_text_single(&self, selected: &Option<SharedString>) -> SharedString {
800        selected
801            .as_ref()
802            .and_then(|key| self.items.iter().find(|item| item.key() == key))
803            .map_or_else(|| self.placeholder.clone(), |item| item.label().clone())
804    }
805
806    fn value_text_multiple(&self, selected_keys: &[SharedString]) -> SharedString {
807        let names: Vec<String> = self
808            .items
809            .iter()
810            .filter(|item| selected_keys.contains(item.key()))
811            .map(|item| item.label().to_string())
812            .collect();
813        if names.is_empty() {
814            self.placeholder.clone()
815        } else {
816            SharedString::from(format_selected_names(&names))
817        }
818    }
819}
820
821/// The controlled halves `render` resolves first: the open flag, the overlay
822/// registration and the selection in whichever mode is active. `controlled`
823/// takes `cx` mutably, so these precede every other read.
824struct SelectControlled {
825    is_open: bool,
826    open_own: Option<gpui::Entity<bool>>,
827    overlay_phase: util::OverlayPhase,
828    dismissal_token: util::OverlayToken,
829    multiple: bool,
830    selected: Option<SharedString>,
831    value_own: Option<gpui::Entity<Option<SharedString>>>,
832    selected_keys: Vec<SharedString>,
833    indices_own: Option<gpui::Entity<Vec<SharedString>>>,
834}
835
836/// Everything one Select frame shares between its painted parts: the
837/// controlled state, the keyed handles (focus, cursor, Shift-range anchor,
838/// scroll, typeahead) and the clear affordance's derived flags. `render`
839/// resolves it once in [`Select::frame`]; the trigger, its key handler, the
840/// clear buttons and the popover list all read this one copy.
841struct SelectFrame {
842    is_open: bool,
843    open_own: Option<gpui::Entity<bool>>,
844    overlay_phase: util::OverlayPhase,
845    dismissal_token: util::OverlayToken,
846    resolved_placement: Rc<Cell<Option<Placement>>>,
847    entry_placement: Placement,
848    multiple: bool,
849    selected: Option<SharedString>,
850    value_own: Option<gpui::Entity<Option<SharedString>>>,
851    selected_keys: Vec<SharedString>,
852    indices_own: Option<gpui::Entity<Vec<SharedString>>>,
853    keys: Vec<SharedString>,
854    focus_handle: gpui::FocusHandle,
855    cursor: gpui::Entity<Option<SharedString>>,
856    cursor_at: Option<usize>,
857    keyboard_press_open: gpui::Entity<Option<bool>>,
858    selection_range: gpui::Entity<SelectSelectionRange>,
859    list_scroll_now: gpui::UniformListScrollHandle,
860    panel_scroll_now: gpui::ScrollHandle,
861    typeahead: gpui::Entity<crate::list_nav::Typeahead>,
862    blur_scope: gpui::FocusHandle,
863    clear_empty: bool,
864    clear_enabled: bool,
865    clear_slots: Vec<util::Interaction>,
866    clear_hovered: bool,
867    clear_selection: SelectAction,
868    anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>>,
869    trigger_pressed: Rc<Cell<bool>>,
870}
871
872type SelectAction = std::sync::Arc<dyn Fn(&mut Window, &mut App)>;
873type OnOpenChange = std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
874type OnSelectionChangeAll =
875    std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
876
877impl RenderOnce for Select {
878    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
879        if !self.placeholder_is_set {
880            self.placeholder = crate::i18n::ui_string(crate::i18n::UiString::SelectPlaceholder, cx);
881        }
882        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
883        let (is_open, open_own) = util::controlled(
884            window,
885            cx,
886            element_id::scoped(&self.id, "open"),
887            self.is_open,
888            self.default_open,
889        );
890        let (overlay_phase, dismissal_token) = util::overlay_scope(
891            window,
892            cx,
893            element_id::scoped(&self.id, "overlay"),
894            is_open,
895            true,
896        );
897        let overlay_active = overlay_phase != util::OverlayPhase::Closed;
898        let multiple = self.selection_mode == SelectionMode::Multiple;
899        let (selected, value_own) = if multiple {
900            (None, None)
901        } else {
902            util::controlled(
903                window,
904                cx,
905                element_id::scoped(&self.id, "value"),
906                self.is_controlled.then_some(self.selected.clone()),
907                self.default_value.clone(),
908            )
909        };
910        let (selected_keys, indices_own) = if multiple {
911            util::controlled(
912                window,
913                cx,
914                element_id::scoped(&self.id, "values"),
915                self.is_multiple_controlled.then(|| {
916                    crate::selection::normalize_selection(self.selected_keys.clone(), true)
917                }),
918                crate::selection::normalize_selection(self.default_selected_keys.clone(), true),
919            )
920        } else {
921            (Vec::new(), None)
922        };
923        let frame = self.frame(
924            SelectControlled {
925                is_open,
926                open_own,
927                overlay_phase,
928                dismissal_token,
929                multiple,
930                selected,
931                value_own,
932                selected_keys,
933                indices_own,
934            },
935            window,
936            cx,
937        );
938        let colors = cx.colors().clone();
939        let layout = cx.layout().clone();
940        self.apply_theme(&colors, cx);
941        let mut field = self.trigger_field(&frame, &colors, &layout, window, cx);
942
943        // Down or Enter on a closed Select opens it, and the arrows then walk
944        // the options -- the same keys React Aria binds.
945        if !self.is_disabled {
946            field = self.trigger_keys(field, &frame);
947        }
948
949        let (value_text, value_slot) = self.trigger_value(&frame, &colors);
950        // `react-aria/dist/private/select/useSelect.mjs` derives the trigger
951        // from `useMenuTrigger({type: 'listbox'})`, so
952        // `.../overlays/useOverlayTrigger.mjs` gives it
953        // `'aria-haspopup': 'listbox'`, `'aria-expanded': isOpen` and
954        // `'aria-controls': isOpen ? overlayId : undefined`; HeroUI renders it
955        // as an RAC `Button` (`select/select.js`), i.e. a native `<button>`.
956        // Only `aria-expanded` ports: `aria-haspopup` and `aria-controls` have
957        // no gpui builder and no id graph to point at (see `crate::a11y`).
958        // `useSelect` names the trigger `aria-labelledby: [valueId, label]`,
959        // which resolves to the field's label followed by the drawn value.
960        field = field
961            .a11y_named(
962                a11y::Role::Button,
963                &a11y::Name::maybe(self.label.clone()).described(Some(value_text)),
964            )
965            .a11y_expanded(frame.is_open);
966        field = field.child(value_slot);
967        field = self.clear_buttons(field, &frame, &colors, window, cx);
968        field = self.trigger_indicator_slot(field, &frame, &colors, window, cx);
969        field = self.trigger_toggle(field, &frame);
970        if !self.is_disabled {
971            field = util::record_focus_bounds(field, &frame.focus_handle, window, cx);
972        }
973
974        // The popup anchors to the trigger bounds — not to the
975        // label-to-description wrapper root — the way RAC's
976        // `useOverlayPosition` positions against the trigger rect.
977        // `scrollable_field_popover` below reads these bounds to flip and
978        // cap the panel; the measure element itself only records them.
979        let field = crate::popover::PopoverTriggerMeasure::new(field, frame.anchor_bounds.clone());
980
981        let mut root = self.field_root(field);
982        root = self.root_dismissals(root, &frame);
983        let blur_scope = frame.blur_scope.clone();
984        if overlay_active && !self.items.is_empty() {
985            root = root.child(self.popover(frame, &colors, &layout, window, cx));
986        }
987
988        root = util::apply_sx(root, &self.sx);
989        root.track_focus(&blur_scope)
990    }
991}
992
993impl Select {
994    /// Mirrors the selection into the live form state and installs the
995    /// reset that restores the default selection.
996    fn sync_form(&self, controlled: &SelectControlled, window: &mut Window, cx: &mut App) {
997        let SelectControlled {
998            multiple,
999            ref selected,
1000            ref selected_keys,
1001            ref value_own,
1002            ref indices_own,
1003            ..
1004        } = *controlled;
1005        let form_default_keys = if multiple {
1006            let reset_keys = if self.is_multiple_controlled {
1007                self.selected_keys.clone()
1008            } else {
1009                self.default_selected_keys.clone()
1010            };
1011            let slot = window.use_keyed_state(
1012                element_id::scoped(&self.id, "form-default"),
1013                cx,
1014                move |_, _| reset_keys,
1015            );
1016            slot.read(cx).clone()
1017        } else {
1018            Vec::new()
1019        };
1020        sync_select_form(
1021            &self.form_state,
1022            select_form_value(&self.items, selected, selected_keys),
1023            self.is_invalid,
1024            !self.is_disabled,
1025        );
1026        let reset_own = value_own.clone();
1027        let reset_keys_own = indices_own.clone();
1028        let reset_state = Rc::downgrade(&self.form_state);
1029        let reset_change = self
1030            .is_controlled
1031            .then(|| self.on_selection_change.clone())
1032            .flatten();
1033        let reset_change_all = self
1034            .is_multiple_controlled
1035            .then(|| self.on_selection_change_all.clone())
1036            .flatten();
1037        let reset_key = self.default_value.clone();
1038        let reset_items = self.items.clone();
1039        let reset_mode = self.selection_mode;
1040        self.form_state.borrow_mut().restore = (reset_own.is_some()
1041            || (multiple && reset_keys_own.is_some())
1042            || reset_change.is_some()
1043            || (multiple && reset_change_all.is_some()))
1044        .then(|| {
1045            util::shared(move |window: &mut Window, cx: &mut App| {
1046                if reset_mode == SelectionMode::Multiple {
1047                    if let Some(state) = reset_state.upgrade() {
1048                        state.borrow_mut().value =
1049                            select_form_value(&reset_items, &None, &form_default_keys);
1050                    }
1051                    if let Some(held) = &reset_keys_own {
1052                        held.update(cx, |selected, cx| {
1053                            *selected = form_default_keys.clone();
1054                            cx.notify();
1055                        });
1056                    }
1057                    if let Some(on_change) = &reset_change_all {
1058                        on_change(&form_default_keys, window, cx);
1059                    }
1060                } else {
1061                    if let Some(state) = reset_state.upgrade() {
1062                        state.borrow_mut().value = select_form_value(&reset_items, &reset_key, &[]);
1063                    }
1064                    if let Some(held) = &reset_own {
1065                        held.update(cx, |selected, cx| {
1066                            *selected = reset_key.clone();
1067                            cx.notify();
1068                        });
1069                    }
1070                    if let Some(on_change) = &reset_change {
1071                        on_change(&reset_key, window, cx);
1072                    }
1073                }
1074            }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
1075        });
1076    }
1077
1078    /// Resolves the frame's keyed handles and derived flags on top of the
1079    /// controlled state.
1080    fn frame(
1081        &self,
1082        controlled: SelectControlled,
1083        window: &mut Window,
1084        cx: &mut App,
1085    ) -> SelectFrame {
1086        self.sync_form(&controlled, window, cx);
1087        let SelectControlled {
1088            is_open,
1089            open_own,
1090            overlay_phase,
1091            dismissal_token,
1092            multiple,
1093            selected,
1094            value_own,
1095            selected_keys,
1096            indices_own,
1097        } = controlled;
1098        // The field popover resolves flips during prepaint. Keep the
1099        // requested placement and the physical side in keyed state so the
1100        // next entry frame uses the side that was actually painted.
1101        let (resolved_placement, entry_placement) =
1102            crate::popover::field_placement_feedback(window, cx, &self.id, self.placement);
1103
1104        // The collection's keys in row order — the channel the selection,
1105        // the cursor and every report address.
1106        let keys: Vec<SharedString> = self.items.iter().map(|item| item.key().clone()).collect();
1107
1108        // The trigger is what holds focus, so the open list can be walked with
1109        // the arrows the way v3's is.
1110        let focus_handle =
1111            window.use_keyed_state(element_id::scoped(&self.id, "focus"), cx, |_, cx| {
1112                cx.focus_handle().tab_stop(true)
1113            });
1114        let focus_handle = focus_handle.read(cx).clone();
1115        self.form_state.borrow_mut().focus = Some(focus_handle.clone());
1116        // Which row the keyboard is on, held as the item's *key* so the cursor
1117        // stays on the same item when the caller reorders the collection.
1118        let cursor = window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| {
1119            None::<SharedString>
1120        });
1121        let cursor_key = cursor.read(cx).clone();
1122        let cursor_at = cursor_key
1123            .as_ref()
1124            .and_then(|key| keys.iter().position(|k| k == key));
1125        let keyboard_press_open = window.use_keyed_state(
1126            element_id::scoped(&self.id, "keyboard-press"),
1127            cx,
1128            |_, _| None::<bool>,
1129        );
1130        // The Shift-range anchor lives beside the cursor, keyed off the same
1131        // instance id, so two selects never share an anchor and a closed
1132        // popover leaves its anchor standing for the reopen.
1133        let selection_range =
1134            window.use_keyed_state(element_id::scoped(&self.id, "range"), cx, |_, _| {
1135                SelectSelectionRange::default()
1136            });
1137        // v3's list is `overflow-y-auto`, and React Aria keeps the focused
1138        // option in view. Both need a handle: the virtual list has its own kind,
1139        // and a plain scrolling div has the other. `use_keyed_state` takes `cx`
1140        // mutably, so they precede the theme.
1141        let list_scroll =
1142            window.use_keyed_state(element_id::scoped(&self.id, "list-scroll"), cx, |_, _| {
1143                gpui::UniformListScrollHandle::new()
1144            });
1145        let panel_scroll =
1146            window.use_keyed_state(element_id::scoped(&self.id, "panel-scroll"), cx, |_, _| {
1147                gpui::ScrollHandle::new()
1148            });
1149        let list_scroll_now = list_scroll.read(cx).clone();
1150        let panel_scroll_now = panel_scroll.read(cx).clone();
1151        // The letters typed so far, which a search resetting every frame could
1152        // not accumulate.
1153        let typeahead =
1154            window.use_keyed_state(element_id::scoped(&self.id, "typed"), cx, |_, _| {
1155                crate::list_nav::Typeahead::default()
1156            });
1157
1158        // Pinned `usePopover` closes when focus leaves the trigger-plus-list
1159        // scope. Blur deliberately leaves focus on its destination.
1160        let blur_base = element_id::scoped(&self.id, "select");
1161        let blur_close_own = open_own.clone();
1162        let blur_open_change = self.on_open_change.clone();
1163        let blur_scope = util::close_on_blur(window, cx, &blur_base, is_open, move |window, cx| {
1164            if let Some(held) = &blur_close_own {
1165                held.update(cx, |v, cx| {
1166                    *v = false;
1167                    cx.notify();
1168                });
1169            }
1170            if let Some(cb) = &blur_open_change {
1171                cb(&false, window, cx);
1172            }
1173        });
1174
1175        // `setSelectedKeys(empty)` clears even a required Select. A controlled
1176        // owner's value and live form data stay authoritative until it updates.
1177        let clear_empty = if multiple {
1178            selected_keys.is_empty()
1179        } else {
1180            selected.is_none()
1181        };
1182        let has_clear = !self.clear_buttons.is_empty();
1183        let clear_enabled = has_clear && !self.is_disabled && !clear_empty;
1184        let clear_slots: Vec<_> = (0..self.clear_buttons.len())
1185            .map(|index| {
1186                util::interaction(
1187                    element_id::scoped(&self.id, format!("clear-{index}")),
1188                    window,
1189                    cx,
1190                )
1191            })
1192            .collect();
1193        let clear_hovered = clear_enabled && clear_slots.iter().any(|slot| slot.read(cx).0);
1194        let clear_own = value_own.clone();
1195        let clear_keys_own = indices_own.clone();
1196        let clear_form = self.form_state.clone();
1197        let clear_single = self.on_selection_change.clone();
1198        let clear_multiple = self.on_selection_change_all.clone();
1199        let clear_callback = self.on_clear.clone();
1200        let clear_range = selection_range.clone();
1201        let clear_open = open_own.clone();
1202        let clear_open_callback = self.on_open_change.clone();
1203        let clear_selection = util::shared(move |window: &mut Window, cx: &mut App| {
1204            if !clear_enabled {
1205                return;
1206            }
1207            if multiple {
1208                if let Some(own) = &clear_keys_own {
1209                    clear_form.borrow_mut().value = crate::form::FormValue::Keys(Vec::new());
1210                    own.update(cx, |value, cx| {
1211                        value.clear();
1212                        cx.notify();
1213                    });
1214                }
1215                if let Some(callback) = &clear_multiple {
1216                    callback(&[], window, cx);
1217                }
1218            } else {
1219                if let Some(own) = &clear_own {
1220                    clear_form.borrow_mut().value = crate::form::FormValue::Keys(Vec::new());
1221                    own.update(cx, |value, cx| {
1222                        *value = None;
1223                        cx.notify();
1224                    });
1225                }
1226                if let Some(callback) = &clear_single {
1227                    callback(&None, window, cx);
1228                }
1229                // React Stately's single-selection callback closes an open list.
1230                if is_open {
1231                    if let Some(own) = &clear_open {
1232                        own.update(cx, |value, cx| {
1233                            *value = false;
1234                            cx.notify();
1235                        });
1236                    }
1237                    if let Some(callback) = &clear_open_callback {
1238                        callback(&false, window, cx);
1239                    }
1240                }
1241            }
1242            clear_range.update(cx, |range, _| *range = SelectSelectionRange::default());
1243            if let Some(callback) = &clear_callback {
1244                callback(window, cx);
1245            }
1246        });
1247
1248        let anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>> = window
1249            .use_keyed_state(element_id::scoped(&self.id, "anchor-bounds"), cx, |_, _| {
1250                Rc::new(Cell::new(None))
1251            })
1252            .read(cx)
1253            .clone();
1254        // Whether the pointer went down on the trigger. The panel's
1255        // outside-press dismissal treats the trigger as outside its own bounds,
1256        // so a press on the trigger of an *open* list would dismiss it on the
1257        // mouse-down *and* toggle it through the trigger's own click on the
1258        // mouse-up -- one press, two contradictory reports, and the list ended
1259        // up open. The trigger's capture-phase handler runs before the panel's
1260        // `on_mouse_down_out` in the same dispatch, so the dismissal can see it
1261        // and leave the close to the trigger's click.
1262        let trigger_pressed = Rc::new(Cell::new(false));
1263        SelectFrame {
1264            is_open,
1265            open_own,
1266            overlay_phase,
1267            dismissal_token,
1268            resolved_placement,
1269            entry_placement,
1270            multiple,
1271            selected,
1272            value_own,
1273            selected_keys,
1274            indices_own,
1275            keys,
1276            focus_handle,
1277            cursor,
1278            cursor_at,
1279            keyboard_press_open,
1280            selection_range,
1281            list_scroll_now,
1282            panel_scroll_now,
1283            typeahead,
1284            blur_scope,
1285            clear_empty,
1286            clear_enabled,
1287            clear_slots,
1288            clear_hovered,
1289            clear_selection,
1290            anchor_bounds,
1291            trigger_pressed,
1292        }
1293    }
1294
1295    /// Folds the theme's `select` recipe under the builder values.
1296    fn apply_theme(&mut self, colors: &herogpui_theme::ThemeColors, cx: &App) {
1297        let select_theme = cx.theme().components.select.resolve(&self.recipes);
1298        if !self.variant_is_set {
1299            if let Some(variant) = select_theme.variant {
1300                self.variant = variant;
1301            }
1302        }
1303        self.field.height = self.field.height.or(select_theme.height);
1304        self.field.padding_x = self.field.padding_x.or(select_theme.padding_x);
1305        self.field.padding_y = self.field.padding_y.or(select_theme.padding_y);
1306        if !self.field.is_bare_is_set {
1307            if let Some(is_bare) = select_theme.is_bare {
1308                self.field.is_bare = is_bare;
1309            }
1310        }
1311        self.trigger_text_size = self.trigger_text_size.or(select_theme.trigger_text_size);
1312        self.row_height = self.row_height.or(select_theme.row_height);
1313        self.row_padding_x = self.row_padding_x.or(select_theme.row_padding_x);
1314        self.row_padding_y = self.row_padding_y.or(select_theme.row_padding_y);
1315        self.row_text_size = self.row_text_size.or(select_theme.row_text_size);
1316        self.panel_padding = self.panel_padding.or(select_theme.panel_padding);
1317        self.radius = self.radius.or(select_theme.radius);
1318        if self.row_hover_bg.is_none() {
1319            self.row_hover_bg = select_theme.row_hover_bg.map(|color| color.resolve(colors));
1320        }
1321    }
1322
1323    /// The trigger box: geometry, field chrome, focus ring and hover fade.
1324    fn trigger_field(
1325        &self,
1326        frame: &SelectFrame,
1327        colors: &herogpui_theme::ThemeColors,
1328        layout: &herogpui_theme::LayoutTheme,
1329        window: &mut Window,
1330        cx: &mut App,
1331    ) -> gpui::Stateful<gpui::Div> {
1332        let SelectFrame {
1333            is_open,
1334            ref focus_handle,
1335            clear_hovered,
1336            ..
1337        } = *frame;
1338        let sem = *cx.role(Color::Accent);
1339        // `.select__trigger` is `min-h-9 ... text-sm`.
1340        let field_box = self.field;
1341        let (h, text) = (
1342            field_box.resolved_height(),
1343            self.trigger_text_size.unwrap_or(util::FIELD_TEXT),
1344        );
1345
1346        let trigger_id = element_id::scoped(&self.id, "trigger");
1347        let trigger_selector = format!("select-trigger-{}", id_debug(&self.id));
1348        let trigger_radius = util::field_radius(cx);
1349        let trigger_focused = focus_handle.is_focused(window);
1350        let mut field = gpui::div()
1351            .id(trigger_id)
1352            .debug_selector(move || trigger_selector)
1353            .flex()
1354            .items_center()
1355            .justify_between()
1356            .gap(px(8.))
1357            .when_some(self.font_family.clone(), |field, family| field.font_family(family))
1358            .min_h(h)
1359            .when_some(field_box.height, |el, h| el.h(h))
1360            .px(field_box.resolved_padding_x())
1361            .when_some(field_box.padding_y, |el, p| el.py(p))
1362            // HeroUI's trigger reserves `pe-7` for its always-present
1363            // `.select__indicator`, whether or not a clear button is composed.
1364            // The indicator is taken out of flex flow below, so the value and
1365            // clear slots cannot push it or overlap its hit box.
1366            .relative()
1367            .pr(px(28.))
1368            .text_size(text)
1369            .line_height(px(20.))
1370            .cursor(util::interactive_cursor(cx));
1371
1372        let _border_color = if is_open { sem.color } else { colors.separator };
1373        // `.select__trigger:focus-visible` is `status-focused` -- the offset
1374        // ring, not a field's flush one, which is why the chrome is not told
1375        // about the focus here.
1376        if !field_box.is_bare {
1377            field = util::apply_field_chrome(field, self.variant, self.is_invalid, false, None, cx);
1378        }
1379        if !field_box.is_bare && field_box.focus_ring.unwrap_or(true) && !self.is_disabled {
1380            // The trigger hosts the ring as an overlay child: concentric with
1381            // its own `trigger_radius`, where a spread shadow would keep the
1382            // trigger's radius on a band two pixels further out and blur it.
1383            field = util::ring_overlay_if_focused(
1384                field,
1385                focus_handle,
1386                true,
1387                trigger_radius,
1388                Vec::new(),
1389                window,
1390                cx,
1391            );
1392        }
1393
1394        if !field_box.is_bare && (self.is_invalid || (trigger_focused && util::focus_visible(cx))) {
1395            field = field.bg(match self.variant {
1396                FieldVariant::Primary => colors.field.focus(),
1397                FieldVariant::Secondary => colors.default.color,
1398            });
1399        }
1400
1401        if self.is_disabled {
1402            field = field.opacity(layout.disabled_opacity);
1403        } else if !field_box.is_bare && !self.is_invalid && !trigger_focused {
1404            let idle_bg = match self.variant {
1405                FieldVariant::Primary => colors.field.background,
1406                FieldVariant::Secondary => colors.default.color,
1407            };
1408            let hover_bg = self.trigger_hover_bg.unwrap_or(match self.variant {
1409                FieldVariant::Primary => colors.field.hover(),
1410                // `.select--secondary` hovers `--select-trigger-bg-hover: var(--default-hover)`.
1411                FieldVariant::Secondary => colors.default.hover(),
1412            });
1413            let hover_border = colors.field.border_hover();
1414            // Keep clear-button ownership and trigger focus stable while the
1415            // field surface eases over HeroUI's 150ms ease-smooth transition.
1416            // The nested clear affordance suppresses the trigger endpoint.
1417            field = crate::anim::hover_fade_with_duration_and_easing_suppressed(
1418                field,
1419                element_id::scoped(&self.id, "trigger-hover-fade"),
1420                (idle_bg, hover_bg),
1421                None,
1422                (!clear_hovered).then_some(hover_border),
1423                clear_hovered,
1424                |fill| fill.rounded(trigger_radius),
1425                Some(150),
1426                crate::anim::HoverFadeEasing::EaseSmooth,
1427                window,
1428                cx,
1429            );
1430        }
1431
1432        if self.full_width {
1433            field = field.w_full();
1434        }
1435        field
1436    }
1437
1438    /// Focus tracking and the trigger's key handler.
1439    fn trigger_keys(
1440        &self,
1441        field: gpui::Stateful<gpui::Div>,
1442        frame: &SelectFrame,
1443    ) -> gpui::Stateful<gpui::Div> {
1444        let SelectFrame {
1445            is_open,
1446            ref open_own,
1447            multiple,
1448            ref selected,
1449            ref value_own,
1450            ref selected_keys,
1451            ref indices_own,
1452            ref keys,
1453            ref focus_handle,
1454            ref cursor,
1455            ref keyboard_press_open,
1456            ref selection_range,
1457            ref list_scroll_now,
1458            ref panel_scroll_now,
1459            ref typeahead,
1460            clear_enabled,
1461            ref clear_selection,
1462            ..
1463        } = *frame;
1464        let stops: Vec<usize> = (0..self.items.len())
1465            .filter(|i| !self.disabled_keys.contains(&keys[*i]))
1466            .collect();
1467        // The full key list the range is resolved against -- disabled
1468        // keys keep their positions so range spans stay indexable, while
1469        // `stops` keeps their insertions out of the range.
1470        let collection: Vec<SharedString> = keys.clone();
1471        let selectable: Vec<SharedString> = stops.iter().map(|i| keys[*i].clone()).collect();
1472        let held = cursor.clone();
1473        let wrap = self.should_focus_wrap;
1474        // Every option's text, so a typed letter can find one.
1475        let labels: Vec<String> = self
1476            .items
1477            .iter()
1478            .map(|item| item.label().to_string())
1479            .collect();
1480        let typed = typeahead.clone();
1481        let open_own_keys = open_own.clone();
1482        let value_own_keys = value_own.clone();
1483        let indices_own_keys = indices_own.clone();
1484        let selected_held = selected.clone();
1485        let selected_keys_held = selected_keys.clone();
1486        let form_state_keys = self.form_state.clone();
1487        let on_open_change = self.on_open_change.clone();
1488        let on_select = self.on_selection_change.clone();
1489        let on_select_all = self.on_selection_change_all.clone();
1490        let range_keys = selection_range.clone();
1491        let was_open = is_open;
1492        let virtual_rows = self.row_height.is_some();
1493        let key_list_scroll = list_scroll_now.clone();
1494        let key_panel_scroll = panel_scroll_now.clone();
1495        let fh = focus_handle.clone();
1496        let press_open = keyboard_press_open.clone();
1497        let row_keys = keys.clone();
1498        let clear_keys = clear_selection.clone();
1499        let key_focus = focus_handle.clone();
1500        let handler = SelectTriggerKeys {
1501            stops,
1502            collection,
1503            selectable,
1504            held,
1505            wrap,
1506            labels,
1507            typed,
1508            open_own_keys,
1509            value_own_keys,
1510            indices_own_keys,
1511            selected_held,
1512            selected_keys_held,
1513            form_state_keys,
1514            on_open_change,
1515            on_select,
1516            on_select_all,
1517            range_keys,
1518            was_open,
1519            virtual_rows,
1520            key_list_scroll,
1521            key_panel_scroll,
1522            press_open,
1523            row_keys,
1524            clear_keys,
1525            key_focus,
1526            clear_enabled,
1527            multiple,
1528        };
1529        field
1530            .track_focus(focus_handle)
1531            .key_context("Select")
1532            .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1533                window.focus(&fh, cx);
1534            })
1535            .on_key_down(move |event, window, cx| handler.on_key_down(event, window, cx))
1536    }
1537
1538    /// The trigger's value text and the slot that draws it.
1539    fn trigger_value(
1540        &self,
1541        frame: &SelectFrame,
1542        colors: &herogpui_theme::ThemeColors,
1543    ) -> (SharedString, gpui::AnyElement) {
1544        let SelectFrame {
1545            multiple,
1546            ref selected,
1547            ref selected_keys,
1548            ..
1549        } = *frame;
1550        let value_text = if multiple {
1551            self.value_text_multiple(selected_keys)
1552        } else {
1553            self.value_text_single(selected)
1554        };
1555        let chosen = resolved_keys(&self.items, selected, selected_keys);
1556        let has_value = !chosen.is_empty();
1557        // The chosen rows' labels and positions, walked in collection order so
1558        // the value slot's `selectedItems` and `selectedIndices` agree.
1559        let mut chosen_items: Vec<SharedString> = Vec::with_capacity(chosen.len());
1560        let mut chosen_at: Vec<usize> = Vec::with_capacity(chosen.len());
1561        for (at, item) in self.items.iter().enumerate() {
1562            if chosen.contains(item.key()) {
1563                chosen_items.push(item.label().clone());
1564                chosen_at.push(at);
1565            }
1566        }
1567
1568        // What the trigger draws when the caller does not: v3's
1569        // `defaultChildren`, which a `Select.Value` closure can hand straight
1570        // back for the placeholder case.
1571        let default_children = gpui::div()
1572            // HeroUI's `.select__value` uses `wrap-break-word`: a long
1573            // selected label grows the trigger instead of disappearing into
1574            // an ellipsis. `min_w_0` keeps the value slot from forcing the
1575            // chevron/clear affordance out of the trigger.
1576            .flex_1()
1577            .min_w_0()
1578            .whitespace_normal()
1579            .text_color(if has_value {
1580                colors.foreground
1581            } else {
1582                colors.muted
1583            })
1584            .child(value_text.to_string())
1585            .into_any_element();
1586
1587        // `Select.Value` — a caller-drawn value replaces the trigger's text.
1588        let value_slot = match &self.value_content {
1589            Some(render) => {
1590                let names = chosen_items
1591                    .iter()
1592                    .map(ToString::to_string)
1593                    .collect::<Vec<_>>();
1594                let text = format_selected_names(&names);
1595                gpui::div()
1596                    .flex_1()
1597                    .min_w_0()
1598                    .child(render(util::SelectionValue {
1599                        selected_items: &chosen_items,
1600                        selected_indices: &chosen_at,
1601                        selected_keys: Some(&chosen),
1602                        selected_text: &text,
1603                        is_placeholder: !has_value,
1604                        default_children,
1605                    }))
1606                    .into_any_element()
1607            }
1608            None => default_children,
1609        };
1610        (value_text, value_slot)
1611    }
1612
1613    /// The composed `Select.ClearButton`s, each a stable pointer target over
1614    /// an animated visual.
1615    fn clear_buttons(
1616        &mut self,
1617        mut field: gpui::Stateful<gpui::Div>,
1618        frame: &SelectFrame,
1619        colors: &herogpui_theme::ThemeColors,
1620        window: &mut Window,
1621        cx: &mut App,
1622    ) -> gpui::Stateful<gpui::Div> {
1623        let SelectFrame {
1624            ref focus_handle,
1625            clear_empty,
1626            clear_enabled,
1627            ref clear_slots,
1628            ref clear_selection,
1629            ..
1630        } = *frame;
1631        for (index, (button, slot)) in std::mem::take(&mut self.clear_buttons)
1632            .into_iter()
1633            .zip(clear_slots)
1634            .enumerate()
1635        {
1636            let id = element_id::scoped(&self.id, format!("clear-button-{index}"));
1637            let pressed = clear_enabled && slot.read(cx).1;
1638            let hovered = clear_enabled && slot.read(cx).0;
1639            let scale = if pressed { 0.93 } else { 1. };
1640            let mut visual = gpui::div()
1641                .flex()
1642                .items_center()
1643                .justify_center()
1644                .size(px(20. * scale))
1645                .rounded(px(f32::from(util::small_radius(cx)) * scale))
1646                .when(hovered, |el| el.bg(colors.default.hover()));
1647            visual = if button.children.is_empty() {
1648                visual.child(
1649                    gpui::svg()
1650                        .w(px(12. * scale))
1651                        .h(px(14. * scale))
1652                        .path(icons::CLOSE)
1653                        .text_color(colors.muted),
1654                )
1655            } else {
1656                visual.children(button.children)
1657            };
1658            let mut opacity = crate::anim::Tween::keyed(
1659                &id,
1660                "opacity",
1661                if clear_empty { 0. } else { 1. },
1662                window,
1663                cx,
1664            );
1665            // Upstream hides immediately, but fades in when selection returns.
1666            let reduced = ActiveTheme::reduce_motion(cx);
1667            opacity.snap_if_reduced(reduced || clear_empty);
1668            let visual = if opacity.animates(reduced) {
1669                let from = opacity.from();
1670                let value = opacity.value();
1671                visual
1672                    .with_animation(
1673                        element_id::scoped(&id, format!("opacity-{}", opacity.generation())),
1674                        gpui::Animation::new(std::time::Duration::from_millis(150))
1675                            .with_easing(crate::anim::ease_smooth()),
1676                        move |el, progress| {
1677                            let alpha = from + (1. - from) * progress;
1678                            value.set(alpha);
1679                            el.opacity(alpha)
1680                        },
1681                    )
1682                    .into_any_element()
1683            } else {
1684                opacity.settle();
1685                visual.opacity(opacity.target()).into_any_element()
1686            };
1687            // `.select__clear-button`: keep its 20px layout slot and
1688            // 24px pointer target independent.
1689            // Only the target owns listeners; the animated visual never changes
1690            // their element-id path during a press or an opacity transition.
1691            // `&:active, &[data-pressed="true"]` applies
1692            // `transform: scale(0.93)` to the whole control about its center,
1693            // and CSS transforms rescale hit testing without touching layout,
1694            // so the absolute target tracks the same centered box — 24px at
1695            // rest, 22.32px inset by 1.16px while the press lasts.
1696            let target_size = px(24. * scale);
1697            let target_inset = px((20. - 24. * scale) / 2.);
1698            let mut target = gpui::div()
1699                .id(id.clone())
1700                .absolute()
1701                .left(target_inset)
1702                .top(target_inset)
1703                .size(target_size)
1704                .flex()
1705                .items_center()
1706                .justify_center()
1707                .child(visual)
1708                .debug_selector(move || format!("select-clear-{index}"));
1709            if clear_enabled {
1710                let focus = focus_handle.clone();
1711                let clear = clear_selection.clone();
1712                target = util::track_interaction_on_mouse_down(target, slot, move |window, cx| {
1713                    window.focus(&focus, cx);
1714                    cx.stop_propagation();
1715                })
1716                .cursor(util::interactive_cursor(cx))
1717                .on_mouse_down(gpui::MouseButton::Right, |_, _, cx| cx.stop_propagation())
1718                .on_mouse_down(gpui::MouseButton::Middle, |_, _, cx| cx.stop_propagation())
1719                .on_click(move |event, window, cx| {
1720                    cx.stop_propagation();
1721                    window.prevent_default();
1722                    clear(window, cx);
1723                    if let Some(callback) = &button.on_click {
1724                        callback(event, window, cx);
1725                    }
1726                });
1727            }
1728            field = field.child(
1729                gpui::div()
1730                    .relative()
1731                    .size(px(20.))
1732                    .flex_shrink_0()
1733                    .child(target),
1734            );
1735        }
1736        field
1737    }
1738
1739    /// The absolute `.select__indicator` end slot.
1740    fn trigger_indicator_slot(
1741        &mut self,
1742        mut field: gpui::Stateful<gpui::Div>,
1743        frame: &SelectFrame,
1744        colors: &herogpui_theme::ThemeColors,
1745        window: &mut Window,
1746        cx: &mut App,
1747    ) -> gpui::Stateful<gpui::Div> {
1748        let is_open = frame.is_open;
1749        // `.select__indicator`: clear compositions reserve its absolute end
1750        // slot. HeroUI keeps one down-chevron in the tree and rotates it over
1751        // 150ms; do the same for the built-in SVG. A caller-provided trigger
1752        // indicator receives the live open state and owns its own pixels.
1753        let indicator = match self.trigger_indicator.take() {
1754            Some(render) => render(is_open),
1755            None => crate::anim::rotating_indicator_with_duration(
1756                &element_id::scoped(&self.id, "trigger-indicator"),
1757                is_open,
1758                gpui::svg()
1759                    .size(px(16.))
1760                    .path(icons::CHEVRON_DOWN)
1761                    .text_color(colors.muted)
1762                    .flex_shrink_0(),
1763                150,
1764                window,
1765                cx,
1766            ),
1767        };
1768        field = field.child(
1769            gpui::div()
1770                .absolute()
1771                .right(px(8.))
1772                .top_0()
1773                .bottom_0()
1774                .w(px(16.))
1775                .flex()
1776                .items_center()
1777                .justify_center()
1778                .child(indicator),
1779        );
1780        field
1781    }
1782
1783    /// The trigger's press: toggles the popover and reports the change.
1784    fn trigger_toggle(
1785        &self,
1786        mut field: gpui::Stateful<gpui::Div>,
1787        frame: &SelectFrame,
1788    ) -> gpui::Stateful<gpui::Div> {
1789        let SelectFrame {
1790            is_open,
1791            ref open_own,
1792            multiple,
1793            ref keyboard_press_open,
1794            ref trigger_pressed,
1795            ..
1796        } = *frame;
1797        if !self.is_disabled && (self.on_open_change.is_some() || open_own.is_some()) {
1798            let on_open_change = self.on_open_change.clone();
1799            let own = open_own.clone();
1800            let open = is_open;
1801            let pressed = trigger_pressed.clone();
1802            let keyboard_press_open = keyboard_press_open.clone();
1803            field = field
1804                .capture_any_mouse_down(move |_, _, cx| {
1805                    pressed.set(true);
1806                    let pressed = pressed.clone();
1807                    cx.defer(move |_| pressed.set(false));
1808                })
1809                .on_click(move |event, window, cx| {
1810                    // Enter and Space activate the highlighted option before
1811                    // gpui synthesizes this trigger click. Multiple selection
1812                    // keeps the popover open for the next pick.
1813                    let keyboard = matches!(event, gpui::ClickEvent::Keyboard(_));
1814                    let press_open = keyboard_press_open.update(cx, |value, _| value.take());
1815                    let started_open = if keyboard {
1816                        press_open.unwrap_or(open)
1817                    } else {
1818                        open
1819                    };
1820                    if multiple && started_open && keyboard {
1821                        return;
1822                    }
1823                    // A selection callback can close a controlled popup and
1824                    // redraw between key-down and key-up. Finish that press's
1825                    // close rather than toggling the new frame back open.
1826                    let next_open = !started_open;
1827                    if next_open == open {
1828                        return;
1829                    }
1830                    // Uncontrolled: flip our own copy, or the trigger would be
1831                    // inert without a caller handler.
1832                    if let Some(held) = &own {
1833                        held.update(cx, |v, cx| {
1834                            *v = next_open;
1835                            cx.notify();
1836                        });
1837                    }
1838                    if let Some(cb) = &on_open_change {
1839                        cb(&next_open, window, cx);
1840                    }
1841                    // A pointer press the trigger consumed must not also
1842                    // activate whatever encloses it: gpui bubbles a click to
1843                    // every ancestor listener, so a select embedded in a
1844                    // clickable row -- a menu row hosting an inline select,
1845                    // a pressable card -- opened its popup and fired the
1846                    // enclosing handler from one press, which typically
1847                    // dismissed the surface the popup had just opened over.
1848                    // Only the pointer is stopped: a keyboard activation is
1849                    // synthesized on the focused element, so there is no
1850                    // ancestor press to suppress, and stopping it would take
1851                    // the key away from a parent's own bindings.
1852                    if !keyboard {
1853                        cx.stop_propagation();
1854                    }
1855                });
1856        }
1857        field
1858    }
1859
1860    /// The root: the trigger alone, or wrapped with its label and description.
1861    fn field_root(&self, field: impl IntoElement) -> gpui::Div {
1862        // listbox panel
1863        let mut root = gpui::div().relative();
1864        root = if self.full_width {
1865            root.w_full()
1866        } else {
1867            root.max_w(px(320.))
1868        };
1869        if self.label.is_some() || self.description.is_some() {
1870            let mut wrapper = gpui::div().flex().flex_col().gap(px(4.)).w_full();
1871            // Reuse the shared field slots so the required/invalid/disabled
1872            // treatments match every other control.
1873            if let Some(label) = &self.label {
1874                wrapper = wrapper.child(
1875                    crate::field::Label::new(label.clone())
1876                        .is_required(self.is_required)
1877                        .is_disabled(self.is_disabled)
1878                        .is_invalid(self.is_invalid),
1879                );
1880            }
1881            wrapper = wrapper.child(field);
1882            if !self.is_invalid {
1883                if let Some(desc) = &self.description {
1884                    wrapper = wrapper.child(crate::field::Description::new(desc.clone()));
1885                }
1886            }
1887            root = root.child(wrapper);
1888        } else {
1889            root = root.child(field);
1890        }
1891        root
1892    }
1893
1894    /// Escape, and — for an empty list — the outside press.
1895    fn root_dismissals(&self, mut root: gpui::Div, frame: &SelectFrame) -> gpui::Div {
1896        let SelectFrame {
1897            ref open_own,
1898            ref dismissal_token,
1899            ..
1900        } = *frame;
1901        let overlay_active = frame.overlay_phase != util::OverlayPhase::Closed;
1902        let escape_own = open_own.clone();
1903        let escape_cb = self.on_open_change.clone();
1904        root =
1905            util::dismiss_on_escape_with_token(root, dismissal_token.clone(), move |window, cx| {
1906                if let Some(held) = &escape_own {
1907                    held.update(cx, |v, cx| {
1908                        *v = false;
1909                        cx.notify();
1910                    });
1911                }
1912                if let Some(cb) = &escape_cb {
1913                    cb(&false, window, cx);
1914                }
1915                util::DismissResult::Handled
1916            });
1917
1918        if overlay_active && self.items.is_empty() {
1919            let dismiss_own = open_own.clone();
1920            let dismiss_cb = self.on_open_change.clone();
1921            root = util::dismiss_on_press_outside_with_token(
1922                root,
1923                dismissal_token.clone(),
1924                move |window, cx| {
1925                    if let Some(held) = &dismiss_own {
1926                        held.update(cx, |v, cx| {
1927                            *v = false;
1928                            cx.notify();
1929                        });
1930                    }
1931                    if let Some(cb) = &dismiss_cb {
1932                        cb(&false, window, cx);
1933                    }
1934                    util::DismissResult::Handled
1935                },
1936            );
1937        }
1938        root
1939    }
1940
1941    /// The popover's scrolling `ListBox` surface: `bg-overlay`, the panel
1942    /// radius, the dark-mode hairline and the overlay shadow.
1943    fn panel_surface(
1944        &self,
1945        base: &str,
1946        base_id: &gpui::ElementId,
1947        radius: Pixels,
1948        panel_scroll_now: &gpui::ScrollHandle,
1949        colors: &herogpui_theme::ThemeColors,
1950        layout: &herogpui_theme::LayoutTheme,
1951    ) -> gpui::Stateful<gpui::Div> {
1952        // `useListBox` is named through `useField`, i.e. by the same
1953        // `<Label>` the trigger points at.
1954        let list_name = self.label.clone();
1955        gpui::div()
1956                .w_full()
1957                .flex()
1958                .flex_col()
1959                .p(self.panel_padding.unwrap_or(px(6.)))
1960                .bg(colors.overlay.background)
1961                .rounded(radius)
1962                // v3 gives a floating panel no border: `.popover` and friends are
1963                // `bg-overlay shadow-overlay` and a radius, and dark mode's
1964                // inset hairline is what separates the panel from the page.
1965                .when_some(layout.overlay_hairline, |el, hairline| {
1966                el.border(layout.border_width).border_color(hairline)
1967                })
1968                .shadow(layout.overlay_shadow.clone())
1969                // `.select__popover` is `overflow-y-auto`: a long list scrolls
1970                // rather than being clipped. gpui needs an id for that.
1971                .id(element_id::scoped(base_id, "scroll"))
1972                // RAC's `Select` puts a `ListBox` inside the popover, and
1973                // `react-aria/dist/private/listbox/useListBox.mjs` is one
1974                // literal `role: 'listbox'` with `'aria-orientation'`
1975                // defaulting to vertical. The scroller *is* that list here:
1976                // the rows are its children. `aria-multiselectable` has no
1977                // gpui builder (see `crate::a11y`).
1978                .a11y_named(a11y::Role::ListBox, &a11y::Name::maybe(list_name))
1979                .a11y_orientation(herogpui_core::Orientation::Vertical)
1980                .debug_selector({
1981                    let base = base.to_owned();
1982                    move || format!("{base}-panel")
1983                })
1984                .overflow_y_scroll()
1985                // `overscroll-contain` upstream: a wheel over the popup must
1986                // not scroll the page behind it (ColorPicker pattern).
1987                .occlude()
1988                .track_scroll(panel_scroll_now)
1989                // RAC caps the popover at the available viewport height
1990                // (`calculatePosition`'s `getMaxHeight`); the positioner
1991                // below re-lays the panel out with that cap, so the panel
1992                // carries a viewport-relative bound rather than a fixed one.
1993                .max_h_full()
1994    }
1995
1996    /// The listbox popover: panel surface, dismissal, rows and motion.
1997    fn popover(
1998        &mut self,
1999        frame: SelectFrame,
2000        colors: &herogpui_theme::ThemeColors,
2001        layout: &herogpui_theme::LayoutTheme,
2002        window: &mut Window,
2003        cx: &mut App,
2004    ) -> gpui::Deferred {
2005        let SelectFrame {
2006            open_own,
2007            overlay_phase,
2008            dismissal_token,
2009            resolved_placement,
2010            entry_placement,
2011            multiple,
2012            selected,
2013            value_own,
2014            selected_keys,
2015            indices_own,
2016            keys,
2017            focus_handle,
2018            cursor,
2019            cursor_at,
2020            selection_range,
2021            list_scroll_now,
2022            panel_scroll_now,
2023            anchor_bounds,
2024            trigger_pressed,
2025            ..
2026        } = frame;
2027        // The `debug_selector` spellings `pickers_deep.rs` queries on --
2028        // labels, not ids; the ids beside them derive from `self.id`.
2029        let base = format!("select-list-{}", id_debug(&self.id));
2030        let base_id = element_id::scoped(&self.id, "list");
2031        let options_len = self.items.len();
2032        let panel_interactive = overlay_phase == util::OverlayPhase::Open;
2033        // The entry zoom interpolates the panel's own radius, so one
2034        // binding feeds both the painted shape and the animation.
2035        let radius = self.radius.unwrap_or_else(|| util::container_radius(cx));
2036        let panel = self.panel_surface(&base, &base_id, radius, &panel_scroll_now, colors, layout);
2037
2038        // React Aria dismisses the list on a press outside it. Escape is
2039        // already read by the trigger's key handler, so only the press half
2040        // is added here. A press that started on the trigger is not an
2041        // outside press: the trigger's own click owns the close (and the
2042        // click only fires because the down was not stolen as a dismissal).
2043        let dismiss_own = open_own.clone();
2044        let dismiss_cb = self.on_open_change.clone();
2045        let mut panel =
2046            util::dismiss_on_press_outside_with_token(panel, dismissal_token, move |window, cx| {
2047                if trigger_pressed.get() {
2048                    return util::DismissResult::Declined;
2049                }
2050                if let Some(held) = &dismiss_own {
2051                    held.update(cx, |v, cx| {
2052                        *v = false;
2053                        cx.notify();
2054                    });
2055                }
2056                if let Some(cb) = &dismiss_cb {
2057                    cb(&false, window, cx);
2058                }
2059                util::DismissResult::Handled
2060            });
2061
2062        // `useOption` adds the set position only under virtualization;
2063        // `row_height` is what turns this list into a windowed one.
2064        let row_virtualized = self.row_height.is_some();
2065        let row_disabled_opacity = layout.disabled_opacity;
2066        // Everything a row reads, owned: `uniform_list`'s callback is
2067        // `'static` and is called again on every scroll, so it cannot
2068        // borrow `self` -- and one row builder for both paths is what keeps
2069        // a virtual list drawing the same row as a short one.
2070        let items = self.items.clone();
2071        let sections = self.sections.clone();
2072        let opt_disabled_keys = self.disabled_keys.clone();
2073        // The key list the range is resolved against and the enabled
2074        // keys that may join it, for the Shift-click extension.
2075        let collection_rows: Vec<SharedString> = keys.clone();
2076        let selectable_rows: Vec<SharedString> = (0..options_len)
2077            .filter(|i| !self.disabled_keys.contains(&keys[*i]))
2078            .map(|i| keys[i].clone())
2079            .collect();
2080        let range_rows = selection_range;
2081        let cursor_rows = cursor;
2082        let focus_rows = focus_handle;
2083        let indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>> =
2084            self.indicator.take().map(Rc::from);
2085        #[allow(clippy::type_complexity)]
2086        let item_leading: Option<
2087            Rc<dyn Fn(&SharedString, bool) -> Option<gpui::AnyElement>>,
2088        > = self.item_leading.take().map(Rc::from);
2089        let on_change_all = self.on_selection_change_all.clone();
2090        let on_change_one = self.on_selection_change.clone();
2091        let value_own = value_own;
2092        let open_own = open_own;
2093        let form_state_rows = self.form_state.clone();
2094        let on_close = self.on_open_change.clone();
2095        let base_row = base;
2096        let base_row_id = base_id.clone();
2097        let row_text_size = self.row_text_size.unwrap_or(util::FIELD_TEXT);
2098        let row_padding_x = self.row_padding_x.unwrap_or(px(10.));
2099        let row_font_family = self.row_font_family.clone();
2100        let row_padding_y = self.row_padding_y.unwrap_or(px(6.));
2101        let rows = SelectRows {
2102            colors: colors.clone(),
2103            row_hover_bg: self.row_hover_bg,
2104            multiple,
2105            selected,
2106            selected_keys,
2107            cursor_at,
2108            panel_interactive,
2109            options_len,
2110            row_virtualized,
2111            row_disabled_opacity,
2112            items,
2113            sections,
2114            opt_disabled_keys,
2115            collection_rows,
2116            selectable_rows,
2117            range_rows,
2118            cursor_rows,
2119            focus_rows,
2120            indicator,
2121            item_leading,
2122            on_change_all,
2123            on_change_one,
2124            indices_own,
2125            value_own,
2126            open_own,
2127            form_state_rows,
2128            on_close,
2129            base_row,
2130            base_row_id,
2131            row_text_size,
2132            row_padding_x,
2133            row_font_family,
2134            row_padding_y,
2135        };
2136
2137        match self.row_height {
2138            // Virtual: only the rows in view are built, which is what makes
2139            // a thousand options affordable. The list itself is the scroll
2140            // container: `Infer` sizes it from its rows — the full
2141            // natural height on the positioner's measure pass (so the
2142            // flip sees the real extent, like upstream's `overlaySize`),
2143            // capped to the available height on the capped pass — while
2144            // the ListBox stays `overflow-clip`, as in v3. A fixed inner
2145            // height plus an outer scroller would nest two scroll
2146            // containers and strand rows between them.
2147            Some(row_height) => {
2148                panel = panel.child(
2149                    gpui::uniform_list(
2150                        element_id::scoped(&base_id, "rows"),
2151                        options_len,
2152                        move |range, _window, cx| {
2153                            range
2154                                .map(|i| rows.row(i, Some(row_height), _window, cx))
2155                                .collect::<Vec<_>>()
2156                        },
2157                    )
2158                    .track_scroll(&list_scroll_now)
2159                    .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
2160                    .w_full(),
2161                );
2162            }
2163            None => {
2164                for i in 0..options_len {
2165                    panel = panel.child(rows.row(i, None, window, cx));
2166                }
2167            }
2168        }
2169
2170        let (slide_x, slide_y) = crate::popover::placement_entry_offset(entry_placement);
2171        let zoom = crate::anim::ZoomBox::panel(self.panel_padding.unwrap_or(px(6.)), radius);
2172        let zoom = crate::anim::ZoomBox {
2173            slide_x: (slide_x != 0.0).then(|| px(slide_x)),
2174            slide_y: (slide_y != 0.0).then(|| px(slide_y)),
2175            ..zoom
2176        };
2177        let panel = if overlay_phase == util::OverlayPhase::Exiting {
2178            crate::anim::exiting(
2179                panel,
2180                element_id::scoped(&base_id, "panel-out"),
2181                zoom,
2182                crate::anim::Motion::LIST_OUT,
2183                cx,
2184            )
2185        } else {
2186            crate::anim::entering_zoom(
2187                panel,
2188                element_id::scoped(&base_id, "panel"),
2189                zoom,
2190                crate::anim::Motion::LIST_IN,
2191                cx,
2192            )
2193        };
2194        // RAC positions the popover against the trigger with an 8px gap,
2195        // flips it when the other side has more room, and caps it at the
2196        // available viewport height past a 12px inset — which `Select`
2197        // inherits unchanged from `useOverlayPosition`/`Popover`.
2198        util::floating(
2199            crate::popover::scrollable_field_popover_with_resolved_placement(
2200                anchor_bounds,
2201                self.placement,
2202                Some(resolved_placement),
2203                panel,
2204            ),
2205        )
2206    }
2207}
2208
2209/// The trigger's key handler, holding everything it reads. The handler is
2210/// `'static`, so it owns copies of the frame's state rather than borrowing
2211/// the Select; each branch of the pinned keyboard contract is one method.
2212struct SelectTriggerKeys {
2213    stops: Vec<usize>,
2214    collection: Vec<SharedString>,
2215    selectable: Vec<SharedString>,
2216    held: gpui::Entity<Option<SharedString>>,
2217    wrap: bool,
2218    labels: Vec<String>,
2219    typed: gpui::Entity<crate::list_nav::Typeahead>,
2220    open_own_keys: Option<gpui::Entity<bool>>,
2221    value_own_keys: Option<gpui::Entity<Option<SharedString>>>,
2222    indices_own_keys: Option<gpui::Entity<Vec<SharedString>>>,
2223    selected_held: Option<SharedString>,
2224    selected_keys_held: Vec<SharedString>,
2225    form_state_keys: Rc<RefCell<crate::form::LiveFormFieldState>>,
2226    on_open_change: Option<OnOpenChange>,
2227    on_select: Option<OnSelectionChange>,
2228    on_select_all: Option<OnSelectionChangeAll>,
2229    range_keys: gpui::Entity<SelectSelectionRange>,
2230    was_open: bool,
2231    virtual_rows: bool,
2232    key_list_scroll: gpui::UniformListScrollHandle,
2233    key_panel_scroll: gpui::ScrollHandle,
2234    press_open: gpui::Entity<Option<bool>>,
2235    row_keys: Vec<SharedString>,
2236    clear_keys: SelectAction,
2237    key_focus: gpui::FocusHandle,
2238    clear_enabled: bool,
2239    multiple: bool,
2240}
2241
2242impl SelectTriggerKeys {
2243    fn on_key_down(&self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App) {
2244        let Self {
2245            ref stops,
2246            ref held,
2247            wrap,
2248            ref indices_own_keys,
2249            ref selected_keys_held,
2250            ref form_state_keys,
2251            ref range_keys,
2252            was_open,
2253            ref press_open,
2254            ref row_keys,
2255            ref clear_keys,
2256            ref key_focus,
2257            clear_enabled,
2258            multiple,
2259            ..
2260        } = *self;
2261        if !key_focus.is_focused(window) {
2262            return;
2263        }
2264        let key = event.keystroke.key.as_str();
2265        if !was_open && clear_enabled && matches!(key, "backspace" | "delete") {
2266            cx.stop_propagation();
2267            window.prevent_default();
2268            // This key is consumed before the app root sees it.
2269            util::set_focus_visible(true, cx);
2270            clear_keys(window, cx);
2271            return;
2272        }
2273        if matches!(key, "enter" | "space") {
2274            // The browser's default newline can synthesize another
2275            // Enter through beforeinput, including while held.
2276            cx.stop_propagation();
2277            if event.is_held {
2278                return;
2279            }
2280            press_open.update(cx, |value, _| *value = Some(was_open));
2281        }
2282        if !was_open {
2283            self.on_closed_key(key, window, cx);
2284            return;
2285        }
2286        let from = held
2287            .read(cx)
2288            .as_ref()
2289            .and_then(|k| row_keys.iter().position(|key| key == k));
2290        let modifiers = event.keystroke.modifiers;
2291        // Pinned React Aria 3.51.0 `useSelectableCollection`
2292        // answers `Mod+A` with `selectAll` -- multiple mode only,
2293        // and only while the list is open. Pinned SelectState
2294        // drops the symbolic `all`: the uncontrolled set becomes
2295        // every enabled key while `on_selection_change_all` stays
2296        // silent, a repeat over a complete selection is not a
2297        // toggle, and a controlled owner's state is not this
2298        // keystroke's to mutate.
2299        if key == "a"
2300            && modifiers.secondary()
2301            && !modifiers.shift
2302            && !modifiers.alt
2303            && if cfg!(target_os = "macos") {
2304                !modifiers.control
2305            } else {
2306                !modifiers.platform
2307            }
2308            && multiple
2309        {
2310            let all: Vec<SharedString> = stops.iter().map(|i| row_keys[*i].clone()).collect();
2311            let complete = all.iter().all(|key| selected_keys_held.contains(key));
2312            if !complete {
2313                if let Some(held) = &indices_own_keys {
2314                    form_state_keys.borrow_mut().value = crate::form::FormValue::Keys(all.clone());
2315                    held.update(cx, |selected, cx| {
2316                        *selected = all.clone();
2317                        cx.notify();
2318                    });
2319                    range_keys.update(cx, |range, _| {
2320                        *range = SelectSelectionRange {
2321                            is_all: true,
2322                            ..SelectSelectionRange::default()
2323                        };
2324                    });
2325                }
2326            }
2327            cx.stop_propagation();
2328            return;
2329        }
2330        // Pinned React Aria 3.51.0 binds PageUp/PageDown only
2331        // while the collection has a focused key: mouse-opening a
2332        // selection-less Select leaves the cursor null, and the
2333        // page keys must stay inert until keyboard navigation
2334        // establishes one. With a cursor the list is
2335        // non-scrollable -- HeroUI v3.2.4 puts the overflow
2336        // scrolling on the Popover while the ListBox element is
2337        // `overflow-clip` -- so a page takes the enabled end:
2338        // `stops` already omits disabled rows, whatever the
2339        // list's length, row height, or scroll state. A closed
2340        // trigger answers no page key at all, so the closed
2341        // branch above never sees them.
2342        let page_move = match key {
2343            "pagedown" if from.is_some() => stops.last().copied(),
2344            "pageup" if from.is_some() => stops.first().copied(),
2345            _ => None,
2346        }
2347        .filter(|next| Some(*next) != from);
2348        let next_move = page_move.map_or_else(
2349            || crate::list_nav::resolve(stops, from, key, wrap),
2350            crate::list_nav::Move::To,
2351        );
2352        self.apply_move(next_move, from, key, modifiers, window, cx);
2353    }
2354
2355    /// A closed trigger: Down and Up open the list; a single select answers
2356    /// letters where it stands.
2357    fn on_closed_key(&self, key: &str, window: &mut Window, cx: &mut App) {
2358        let Self {
2359            ref labels,
2360            ref stops,
2361            ref typed,
2362            ref open_own_keys,
2363            ref value_own_keys,
2364            ref selected_held,
2365            ref form_state_keys,
2366            ref on_open_change,
2367            ref on_select,
2368            ref row_keys,
2369            multiple,
2370            ..
2371        } = *self;
2372        // Closed: Down and Up open the list. Enter and Space are
2373        // *not* handled here -- the trigger has a click listener
2374        // and gpui fires those on Enter and Space for a focused
2375        // element, so handling them again would open and close
2376        // the list in one keystroke.
2377        if matches!(key, "down" | "up") {
2378            if let Some(held) = &open_own_keys {
2379                held.update(cx, |v, cx| {
2380                    *v = true;
2381                    cx.notify();
2382                });
2383            }
2384            if let Some(cb) = &on_open_change {
2385                cb(&true, window, cx);
2386            }
2387            return;
2388        }
2389        // A closed select still answers letters in single
2390        // mode: React Aria picks the matching option where it
2391        // stands rather than opening the list. A multiple
2392        // select answers no typeahead on the closed trigger --
2393        // the closed pick reports through the single-key
2394        // callback, which a set-valued selection has no use
2395        // for. Open, the RAC ListBox keeps its type-select and
2396        // moves the cursor without selecting, exactly as in
2397        // single mode.
2398        if multiple {
2399            return;
2400        }
2401        if !crate::list_nav::is_typeahead_key(key) {
2402            return;
2403        }
2404        let now = web_time::Instant::now();
2405        let (query, repeat) = typed.update(cx, |t, _| {
2406            let query = t.push(key, now);
2407            (query, t.is_repeat())
2408        });
2409        let selected_at = selected_held
2410            .as_ref()
2411            .and_then(|k| row_keys.iter().position(|key| key == k));
2412        let Some(found) = crate::list_nav::typeahead(labels, stops, selected_at, &query, repeat)
2413        else {
2414            return;
2415        };
2416        let found = row_keys[found].clone();
2417        if let Some(held) = &value_own_keys {
2418            form_state_keys.borrow_mut().value = crate::form::FormValue::Keys(vec![found.clone()]);
2419            held.update(cx, |v, cx| {
2420                *v = Some(found.clone());
2421                cx.notify();
2422            });
2423        }
2424        if let Some(cb) = &on_select {
2425            cb(&Some(found), window, cx);
2426        }
2427    }
2428
2429    /// The open list's answer to a resolved move: an arrow, Home/End or page
2430    /// move of the cursor (with the multiple mode's Shift extension), Enter or
2431    /// Space on the cursor row, or typeahead, which moves the cursor and
2432    /// selects nothing.
2433    fn apply_move(
2434        &self,
2435        next_move: crate::list_nav::Move,
2436        from: Option<usize>,
2437        key: &str,
2438        modifiers: gpui::Modifiers,
2439        window: &mut Window,
2440        cx: &mut App,
2441    ) {
2442        let Self {
2443            ref collection,
2444            ref selectable,
2445            ref held,
2446            ref labels,
2447            ref stops,
2448            ref typed,
2449            ref value_own_keys,
2450            ref indices_own_keys,
2451            ref selected_keys_held,
2452            ref form_state_keys,
2453            ref on_select,
2454            ref on_select_all,
2455            ref range_keys,
2456            virtual_rows,
2457            ref key_list_scroll,
2458            ref key_panel_scroll,
2459            ref row_keys,
2460            multiple,
2461            ..
2462        } = *self;
2463        match next_move {
2464            crate::list_nav::Move::To(at) => {
2465                // The pinned registrations install no Home/End
2466                // handler for an unregistered chord -- Cmd- or
2467                // Ctrl-bearing on macOS, Alt- or platform-bearing
2468                // elsewhere -- so in either mode the whole event
2469                // stays inert: no cursor move, no selection, no
2470                // preventDefault.
2471                if matches!(key, "home" | "end")
2472                    && !home_end_registered(modifiers, cfg!(target_os = "macos"))
2473                {
2474                    return;
2475                }
2476                // A Shift extension reaches from the cursor, and a
2477                // null cursor is nothing to reach from: pinned
2478                // `useSelectableCollection` extends only from a
2479                // focused key, so the registered Shift+Home/End is
2480                // wholly inert rather than seating a fresh cursor.
2481                if matches!(key, "home" | "end") && modifiers.shift && from.is_none() {
2482                    return;
2483                }
2484                let next = row_keys[at].clone();
2485                held.update(cx, |v, cx| {
2486                    *v = Some(next.clone());
2487                    cx.notify();
2488                });
2489                // React Aria keeps the focused option in view; the
2490                // highlight walking off the bottom of the list looks
2491                // like the arrows have stopped working.
2492                if virtual_rows {
2493                    key_list_scroll.scroll_to_item(at, gpui::ScrollStrategy::Center);
2494                } else {
2495                    key_panel_scroll.scroll_to_item(at);
2496                }
2497                // Pinned `useSelectableCollection`: Shift extends a
2498                // multiple selection over exactly the chords the
2499                // platform's registration admits, so the extension
2500                // gate reuses the Home/End registration map -- the
2501                // pinned matcher reads the browser's canonical
2502                // modifier flags only, and GPUI's `function` flag
2503                // never reaches it. The pinned arrow delegates
2504                // return null at an enabled boundary, so a
2505                // Shift+Arrow that held ran no extension at all
2506                // and must not report; Home and End resolve their
2507                // end key again, so their repeated registered
2508                // extension does report; and the page keys only
2509                // reach here off the unchanged filter above.
2510                let exact_shift_navigation =
2511                    home_end_registered(modifiers, cfg!(target_os = "macos"));
2512                let extends_selection = multiple
2513                    && modifiers.shift
2514                    && exact_shift_navigation
2515                    && shift_home_end_extends(key, modifiers.control, cfg!(target_os = "macos"))
2516                    && (matches!(key, "home" | "end") || Some(at) != from);
2517                if extends_selection {
2518                    let range = range_keys.read(cx).clone();
2519                    let next_selection = extend_selection_range(
2520                        selected_keys_held,
2521                        collection,
2522                        selectable,
2523                        &range,
2524                        &next,
2525                    );
2526                    range_keys.update(cx, |range, _| {
2527                        if range.anchor.is_none() {
2528                            range.anchor = Some(next.clone());
2529                        }
2530                        range.current = Some(next);
2531                        range.is_all = false;
2532                    });
2533                    // The uncontrolled set and the form only move
2534                    // when the extension actually changed something,
2535                    // but pinned `useMultipleSelectionState` with
2536                    // Select's `allowDuplicateSelectionEvents`
2537                    // reports every extension it is handed -- so a
2538                    // repeated registered Shift+Home/End that
2539                    // resolved the end already held still reports.
2540                    if next_selection != *selected_keys_held {
2541                        if let Some(held) = &indices_own_keys {
2542                            form_state_keys.borrow_mut().value =
2543                                crate::form::FormValue::Keys(next_selection.clone());
2544                            held.update(cx, |selected, cx| {
2545                                *selected = next_selection.clone();
2546                                cx.notify();
2547                            });
2548                        }
2549                    }
2550                    if let Some(cb) = &on_select_all {
2551                        cb(&next_selection, window, cx);
2552                    }
2553                }
2554            }
2555            crate::list_nav::Move::Activate => {
2556                // Select on key-down; the trigger click owns closing
2557                // on key-up.
2558                let Some(at) = from else { return };
2559                let key = &row_keys[at];
2560                if multiple {
2561                    let added = !selected_keys_held.contains(key);
2562                    let mut next = selected_keys_held.clone();
2563                    toggle_key(&mut next, key);
2564                    let next = in_collection_order(&next, row_keys);
2565                    // Pinned `toggleSelection` re-anchors on the
2566                    // add, and a deselect only ends a raw `all`
2567                    // so the next Shift move extends instead of
2568                    // collapsing to its target.
2569                    if added {
2570                        range_keys.update(cx, |range, _| {
2571                            range.anchor = Some(key.clone());
2572                            range.current = Some(key.clone());
2573                            range.is_all = false;
2574                        });
2575                    } else {
2576                        range_keys.update(cx, |range, _| {
2577                            if range.is_all {
2578                                *range = SelectSelectionRange::default();
2579                            }
2580                        });
2581                    }
2582                    if let Some(held) = &indices_own_keys {
2583                        form_state_keys.borrow_mut().value =
2584                            crate::form::FormValue::Keys(next.clone());
2585                        held.update(cx, |selected, cx| {
2586                            *selected = next.clone();
2587                            cx.notify();
2588                        });
2589                    }
2590                    if let Some(cb) = &on_select_all {
2591                        cb(&next, window, cx);
2592                    }
2593                    return;
2594                }
2595                if let Some(held) = &value_own_keys {
2596                    form_state_keys.borrow_mut().value =
2597                        crate::form::FormValue::Keys(vec![key.clone()]);
2598                    held.update(cx, |v, cx| {
2599                        *v = Some(key.clone());
2600                        cx.notify();
2601                    });
2602                }
2603                if let Some(cb) = &on_select {
2604                    cb(&Some(key.clone()), window, cx);
2605                }
2606            }
2607            crate::list_nav::Move::Ignore => {
2608                // Typeahead moves the cursor over the open list in
2609                // either mode -- the open RAC ListBox keeps its
2610                // type-select in multiple mode too -- and the move
2611                // selects nothing.
2612                if !crate::list_nav::is_typeahead_key(key) {
2613                    return;
2614                }
2615                let now = web_time::Instant::now();
2616                let (query, repeat) = typed.update(cx, |t, _| {
2617                    let query = t.push(key, now);
2618                    (query, t.is_repeat())
2619                });
2620                if let Some(found) = crate::list_nav::typeahead(labels, stops, from, &query, repeat)
2621                {
2622                    let found = row_keys[found].clone();
2623                    held.update(cx, |v, cx| {
2624                        *v = Some(found);
2625                        cx.notify();
2626                    });
2627                }
2628            }
2629        }
2630    }
2631}
2632
2633/// Everything an option row reads, owned: `uniform_list`'s callback is
2634/// `'static` and is called again on every scroll, so it cannot borrow the
2635/// Select -- and one row builder for both paths is what keeps a virtual list
2636/// drawing the same row as a short one.
2637struct SelectRows {
2638    /// The theme tokens the row draws with, copied out: `cx.colors()` hands
2639    /// back a borrow of the app, which a `'static` closure cannot hold.
2640    colors: herogpui_theme::ThemeColors,
2641    row_hover_bg: Option<gpui::Hsla>,
2642    multiple: bool,
2643    selected: Option<SharedString>,
2644    selected_keys: Vec<SharedString>,
2645    cursor_at: Option<usize>,
2646    panel_interactive: bool,
2647    options_len: usize,
2648    row_virtualized: bool,
2649    row_disabled_opacity: f32,
2650    items: Vec<PickerItem>,
2651    sections: Vec<(SharedString, SharedString)>,
2652    opt_disabled_keys: HashSet<SharedString>,
2653    collection_rows: Vec<SharedString>,
2654    selectable_rows: Vec<SharedString>,
2655    range_rows: gpui::Entity<SelectSelectionRange>,
2656    cursor_rows: gpui::Entity<Option<SharedString>>,
2657    focus_rows: gpui::FocusHandle,
2658    indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>>,
2659    #[allow(clippy::type_complexity)]
2660    item_leading: Option<Rc<dyn Fn(&SharedString, bool) -> Option<gpui::AnyElement>>>,
2661    on_change_all: Option<OnSelectionChangeAll>,
2662    on_change_one: Option<OnSelectionChange>,
2663    indices_own: Option<gpui::Entity<Vec<SharedString>>>,
2664    value_own: Option<gpui::Entity<Option<SharedString>>>,
2665    open_own: Option<gpui::Entity<bool>>,
2666    form_state_rows: Rc<RefCell<crate::form::LiveFormFieldState>>,
2667    on_close: Option<OnOpenChange>,
2668    base_row: String,
2669    base_row_id: gpui::ElementId,
2670    row_text_size: Pixels,
2671    row_padding_x: Pixels,
2672    row_font_family: Option<SharedString>,
2673    row_padding_y: Pixels,
2674}
2675
2676impl SelectRows {
2677    /// One option row, with its section header when one precedes it.
2678    fn row(
2679        &self,
2680        i: usize,
2681        fixed_h: Option<Pixels>,
2682        window: &mut Window,
2683        cx: &mut App,
2684    ) -> gpui::AnyElement {
2685        let Self {
2686            ref colors,
2687            multiple,
2688            ref selected,
2689            ref selected_keys,
2690            cursor_at,
2691            panel_interactive,
2692            options_len,
2693            row_virtualized,
2694            row_disabled_opacity,
2695            ref items,
2696            ref sections,
2697            ref opt_disabled_keys,
2698            ref focus_rows,
2699            ref indicator,
2700            ref item_leading,
2701            ref base_row,
2702            ref base_row_id,
2703            row_text_size,
2704            row_padding_x,
2705            ref row_font_family,
2706            row_padding_y,
2707            ..
2708        } = *self;
2709        let row_muted = colors.muted;
2710        let row_fg = colors.foreground;
2711        let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
2712        let base = &base_row;
2713        let base_id = &base_row_id;
2714        let opt = &items[i];
2715        let row_key = opt.key().clone();
2716        let focus_click = focus_rows.clone();
2717        let mut rows = Vec::new();
2718        // `ListBox.Section`'s `Header`: `text-xs` in the muted colour,
2719        // above the option it introduces.
2720        if let Some((_, label)) = sections.iter().find(|(at, _)| at == &row_key) {
2721            rows.push(
2722                gpui::div()
2723                    .px(px(8.))
2724                    .pt(px(6.))
2725                    .pb(px(4.))
2726                    .text_size(px(12.))
2727                    .line_height(px(16.))
2728                    .font_weight(gpui::FontWeight::MEDIUM)
2729                    .text_color(row_muted)
2730                    .child(label.to_string())
2731                    .into_any_element(),
2732            );
2733        }
2734        let is_sel = if multiple {
2735            selected_keys.contains(&row_key)
2736        } else {
2737            selected.as_ref() == Some(&row_key)
2738        };
2739        let has_indicator_slot = indicator.is_some() || is_sel;
2740        let opt_disabled = opt_disabled_keys.contains(&row_key);
2741        let row_selector = format!("{base}-opt-{i}");
2742        let mut item = gpui::div()
2743                        .id(element_id::indexed(base_id, "opt", i))
2744                        // `useOption.mjs`: `role: 'option'` with
2745                        // `'aria-selected': selectionMode !== 'none' ?
2746                        // isSelected : undefined`. A Select's list always
2747                        // selects, so the flag is unconditional here. Its
2748                        // `aria-posinset`/`aria-setsize` pair is set only
2749                        // `if (isVirtualized)`, which is exactly this port's
2750                        // `row_height` path.
2751                        .a11y_named(
2752                            a11y::Role::ListBoxOption,
2753                            &a11y::Name::labelled(opt.label().clone()),
2754                        )
2755                        .a11y_selected(is_sel)
2756                        .when(row_virtualized, |item| {
2757                            item.a11y_set_position(i, options_len)
2758                        })
2759                        // The trigger keeps the real focus while the list is
2760                        // open, and a cursor walks the rows -- upstream's
2761                        // `shouldUseVirtualFocus`. gpui states that relation
2762                        // on the descendant rather than on the container.
2763                        .when(cursor_at == Some(i), |item| item.a11y_active_descendant())
2764                        .debug_selector(move || row_selector)
2765                        .flex()
2766                        .items_center()
2767                        .justify_between()
2768                        // Every menu row in v3 is a `.list-box-item`: `min-h-9
2769                        // rounded-2xl px-2 py-1.5 gap-3` at `text-sm`. A
2770                        // `row_height` list is virtualized, and its rows are
2771                        // exactly that tall -- the 36px floor would otherwise
2772                        // overflow any shorter row it was asked for.
2773                        .map(|item| match fixed_h {
2774                            Some(row_h) => item.h(row_h),
2775                            None => item.min_h(util::FIELD_HEIGHT),
2776                        })
2777                        .rounded(util::soft_radius(cx))
2778                        .px(row_padding_x)
2779                        .py(row_padding_y)
2780                        .gap(px(12.))
2781                        .text_size(row_text_size)
2782                        .line_height(px(20.))
2783                        // HeroUI's list-box item reserves `pe-7` whenever its
2784                        // indicator slot is present; the indicator itself is
2785                        // absolute at the inline end. Keeping it out of flex
2786                        // flow prevents long labels from pushing the checkmark.
2787                        .relative()
2788                        .when(has_indicator_slot, |row| row.pr(px(28.)));
2789        if let Some(family) = row_font_family.clone() {
2790            item = item.font_family(family);
2791        }
2792
2793        if opt_disabled {
2794            item = item.opacity(row_disabled_opacity);
2795        } else if panel_interactive {
2796            item = item
2797                .cursor(util::interactive_cursor(cx))
2798                .hover(move |s| s.bg(row_hover_bg));
2799        }
2800
2801        // `.list-box-item[data-selected]` is indicator-only in v3:
2802        // selection does not recolour or bold the option label.
2803        item = item.text_color(row_fg);
2804
2805        // `status-focused` is an overlay shadow in v3. A border
2806        // changes the row's content geometry when the cursor moves.
2807        // The ring goes on `item` rather than on anything the press
2808        // ramp below produces: `anim::pressed_with_background_ramp`
2809        // refines this same element, and `item` is the one that both
2810        // carries `soft_radius` and holds the cursor the ring reports.
2811        item = util::with_focus_ring_overlay(
2812            item,
2813            util::shows_focus_ring(cursor_at == Some(i), cx),
2814            true,
2815            util::soft_radius(cx),
2816            Vec::new(),
2817            cx,
2818        );
2819
2820        // HeroUI's ListBox.Item does not add an ellipsis rule. Keep
2821        // normal text flow in both natural and virtual rows; the
2822        // caller owns the fixed row geometry when `row_height` is
2823        // supplied, just as the upstream Virtualizer owns its
2824        // `rowHeight` layout.
2825        // `ListBox.Item`'s render function draws whatever precedes the
2826        // text. It goes in before the label so the row's `gap-3` sets
2827        // it off, and it is `flex_none` so a long label cannot squeeze
2828        // a swatch or avatar out of its own size.
2829        if let Some(render) = &item_leading {
2830            if let Some(content) = render(&row_key, is_sel) {
2831                item = item.child(gpui::div().flex_none().flex().items_center().child(content));
2832            }
2833        }
2834        let label = gpui::div().flex_1().min_w_0().whitespace_normal();
2835        item = item.child(label.child(opt.label().to_string()));
2836
2837        match &indicator {
2838            Some(render) => {
2839                item = item.child(
2840                    gpui::div()
2841                        .absolute()
2842                        .top_0()
2843                        .bottom_0()
2844                        .right(px(8.))
2845                        .w(px(16.))
2846                        .flex()
2847                        .items_center()
2848                        .justify_center()
2849                        .child(render(is_sel)),
2850                );
2851            }
2852            None if is_sel => {
2853                item = item.child(
2854                    gpui::div()
2855                        .absolute()
2856                        .top_0()
2857                        .bottom_0()
2858                        .right(px(8.))
2859                        .w(px(16.))
2860                        .flex()
2861                        .items_center()
2862                        .justify_center()
2863                        .child(
2864                            gpui::svg()
2865                                        .size(px(13.))
2866                                        .path(icons::CHECK)
2867                                        // HeroUI's default list-item indicator is
2868                                        // `text-default-foreground`, independent of
2869                                        // the accent selection role.
2870                                        .text_color(row_fg),
2871                        ),
2872                );
2873            }
2874            None => {}
2875        }
2876
2877        // `.list-box-item:active` scales an option to 98% over the
2878        // pinned 250ms ease-out-quart transition. Wrap before the
2879        // selection listener is attached so the stable slot owns the
2880        // row's hit target and the click remains reachable after the
2881        // visual skin moves inside it.
2882        if panel_interactive && !opt_disabled {
2883            item = crate::anim::pressed_with_background_ramp(
2884                item,
2885                crate::anim::PressBox {
2886                    height: fixed_h.unwrap_or(util::FIELD_HEIGHT),
2887                    padding_x: Some(row_padding_x),
2888                    width: None,
2889                    min_width: None,
2890                    text_size: row_text_size,
2891                    line_height: px(20.),
2892                    gap: px(12.),
2893                    radius: util::soft_radius(cx),
2894                    shrink_x: false,
2895                    scale: crate::anim::PRESSED_SCALE_SUBTLE,
2896                },
2897                None,
2898                crate::anim::LIST_ITEM_PRESS,
2899                None,
2900                window,
2901                cx,
2902            );
2903        }
2904
2905        if panel_interactive && !opt_disabled {
2906            item = self.attach_pick(item, row_key, focus_click);
2907        }
2908
2909        rows.push(item.into_any_element());
2910        gpui::div()
2911                    .flex()
2912                    .flex_col()
2913                    // Keep the row wrapper at the panel's inline extent in
2914                    // both the natural and virtual paths. The stable press
2915                    // slot below uses `w_full`; without the same constraint
2916                    // on natural rows, a long label expands the wrapper to
2917                    // its min-content width and the option stops wrapping.
2918                    .w_full()
2919                    .when_some(fixed_h, |el, h| el.h(h))
2920                    .children(rows)
2921                    .into_any_element()
2922    }
2923
2924    /// The row's pointer pick: a toggle or Shift extension in multiple mode,
2925    /// a select-and-close in single mode.
2926    fn attach_pick(
2927        &self,
2928        mut item: gpui::Stateful<gpui::Div>,
2929        row_key: SharedString,
2930        focus_click: gpui::FocusHandle,
2931    ) -> gpui::Stateful<gpui::Div> {
2932        let Self {
2933            multiple,
2934            ref selected_keys,
2935            ref collection_rows,
2936            ref selectable_rows,
2937            ref range_rows,
2938            ref cursor_rows,
2939            ref on_change_all,
2940            ref on_change_one,
2941            ref indices_own,
2942            ref value_own,
2943            ref open_own,
2944            ref form_state_rows,
2945            ref on_close,
2946            ..
2947        } = *self;
2948        if multiple {
2949            if indices_own.is_some() || on_change_all.is_some() {
2950                let current = selected_keys.clone();
2951                let own = indices_own.clone();
2952                let cb = on_change_all.clone();
2953                let form_state_pick = form_state_rows.clone();
2954                let range_click = range_rows.clone();
2955                let collection_click = collection_rows.clone();
2956                let selectable_click = selectable_rows.clone();
2957                let cursor_click = cursor_rows.clone();
2958                let picked_key = row_key;
2959                item = item.on_click(move |ev, window, cx| {
2960                    // A pointer pick must not also activate whatever
2961                    // encloses the select: gpui bubbles a click to
2962                    // every ancestor listener, so a select hosted in
2963                    // a clickable row -- an `is_interactive` menu
2964                    // row, a pressable card -- fired the enclosing
2965                    // handler from the same press that picked an
2966                    // option. Only the pointer is stopped, matching
2967                    // the trigger: a keyboard activation is
2968                    // synthesized on the focused element, so there
2969                    // is no ancestor press to suppress, and stopping
2970                    // it would take the key away from a parent's own
2971                    // bindings.
2972                    if !matches!(ev, gpui::ClickEvent::Keyboard(_)) {
2973                        cx.stop_propagation();
2974                    }
2975                    // Pinned `useSelectableItem` seats the cursor
2976                    // on pointer press, so a Shift+Arrow, page, or
2977                    // Enter that follows starts from the clicked
2978                    // row rather than from a null or stale cursor.
2979                    cursor_click.update(cx, |v, cx| {
2980                        *v = Some(picked_key.clone());
2981                        cx.notify();
2982                    });
2983                    // gpui's own focus-on-press would park focus
2984                    // on the row and deafen the trigger's key
2985                    // handler; the trigger is what holds focus so
2986                    // the open list stays keyboard-walkable.
2987                    window.focus(&focus_click, cx);
2988                    let mut next = current.clone();
2989                    // A Shift click extends from the anchor
2990                    // through `extendSelection`; an ordinary or
2991                    // platform-Mod click toggles and re-anchors on
2992                    // the add, the way pinned `toggleSelection`
2993                    // does -- a deselect only ends a raw `all`.
2994                    if ev.modifiers().shift {
2995                        let range = range_click.read(cx).clone();
2996                        next = extend_selection_range(
2997                            &current,
2998                            &collection_click,
2999                            &selectable_click,
3000                            &range,
3001                            &picked_key,
3002                        );
3003                        range_click.update(cx, |range, _| {
3004                            if range.anchor.is_none() {
3005                                range.anchor = Some(picked_key.clone());
3006                            }
3007                            range.current = Some(picked_key.clone());
3008                            range.is_all = false;
3009                        });
3010                    } else {
3011                        let added = !next.contains(&picked_key);
3012                        toggle_key(&mut next, &picked_key);
3013                        next = in_collection_order(&next, &collection_click);
3014                        if added {
3015                            range_click.update(cx, |range, _| {
3016                                range.anchor = Some(picked_key.clone());
3017                                range.current = Some(picked_key.clone());
3018                                range.is_all = false;
3019                            });
3020                        } else {
3021                            range_click.update(cx, |range, _| {
3022                                if range.is_all {
3023                                    *range = SelectSelectionRange::default();
3024                                }
3025                            });
3026                        }
3027                    }
3028                    if let Some(held) = &own {
3029                        form_state_pick.borrow_mut().value =
3030                            crate::form::FormValue::Keys(next.clone());
3031                        held.update(cx, |selected, cx| {
3032                            *selected = next.clone();
3033                            cx.notify();
3034                        });
3035                    }
3036                    if let Some(cb) = &cb {
3037                        cb(&next, window, cx);
3038                    }
3039                });
3040            }
3041        } else if on_change_one.is_some() || value_own.is_some() || open_own.is_some() {
3042            let on_select = on_change_one.clone();
3043            let on_close = on_close.clone();
3044            let value_own = value_own.clone();
3045            let open_own = open_own.clone();
3046            let form_state_pick = form_state_rows.clone();
3047            let picked_key = row_key;
3048            item = item.on_click(move |ev, window, cx| {
3049                // The same stop as the multi pick above: the pick
3050                // that closes the list must not fire the clickable
3051                // ancestor a hosted select sits in. Keyboard stays
3052                // live for the reason recorded there.
3053                if !matches!(ev, gpui::ClickEvent::Keyboard(_)) {
3054                    cx.stop_propagation();
3055                }
3056                // Uncontrolled: take the selection and close, or
3057                // choosing an option would do nothing.
3058                if let Some(held) = &value_own {
3059                    form_state_pick.borrow_mut().value =
3060                        crate::form::FormValue::Keys(vec![picked_key.clone()]);
3061                    held.update(cx, |v, cx| {
3062                        *v = Some(picked_key.clone());
3063                        cx.notify();
3064                    });
3065                }
3066                if let Some(held) = &open_own {
3067                    held.update(cx, |v, cx| {
3068                        *v = false;
3069                        cx.notify();
3070                    });
3071                }
3072                // A single-mode pick closes the popover, and a
3073                // caller who drives `isOpen` has to hear about it:
3074                // without this the keyed flag flipped while the
3075                // callback stayed silent, so a controlled caller
3076                // still saw the panel open and reopened it on the
3077                // next render. Reported exactly here by a pointer
3078                // pick; the Enter path's close is the trigger's
3079                // own click listener (gpui fires a focused
3080                // element's click on Enter), so it never reaches
3081                // this closure.
3082                if let Some(cb) = &on_close {
3083                    cb(&false, window, cx);
3084                }
3085                if let Some(f) = &on_select {
3086                    f(&Some(picked_key.clone()), window, cx);
3087                }
3088            });
3089        }
3090        item
3091    }
3092}
3093
3094fn live_form_state() -> Rc<RefCell<crate::form::LiveFormFieldState>> {
3095    Rc::new(RefCell::new(crate::form::LiveFormFieldState {
3096        value: crate::form::FormValue::Text(SharedString::default()),
3097        is_invalid: false,
3098        is_successful: true,
3099        focus: None,
3100        restore: None,
3101    }))
3102}
3103
3104/// The form value both modes submit: the selection's keys, each resolved to a
3105/// collection item, in row order — the channel `@heroui/autocomplete` submits
3106/// the same way, and what a keyed collection's hidden input carries upstream.
3107fn select_form_value(
3108    items: &[PickerItem],
3109    single: &Option<SharedString>,
3110    multiple: &[SharedString],
3111) -> crate::form::FormValue {
3112    crate::form::FormValue::Keys(resolved_keys(items, single, multiple))
3113}
3114
3115fn sync_select_form(
3116    state: &Rc<RefCell<crate::form::LiveFormFieldState>>,
3117    value: crate::form::FormValue,
3118    is_invalid: bool,
3119    is_successful: bool,
3120) {
3121    let mut state = state.borrow_mut();
3122    state.value = value;
3123    state.is_invalid = is_invalid;
3124    state.is_successful = is_successful;
3125}
3126
3127fn id_debug(id: &gpui::ElementId) -> String {
3128    format!("{id:?}").trim_matches('"').to_owned()
3129}
3130
3131#[cfg(test)]
3132mod tests {
3133    use super::*;
3134
3135    /// The Home/End gate takes the platform as an explicit bool, so this
3136    /// truth table is free of `cfg!` and mechanically proves both maps from
3137    /// any host: no macOS chord ever extends -- Shift and Alt+Shift move the
3138    /// cursor alone -- while Windows and Linux extend exactly from
3139    /// Control+Shift.
3140    #[test]
3141    fn shift_home_end_extends_only_from_control_outside_macos() {
3142        for key in ["home", "end"] {
3143            assert!(
3144                !shift_home_end_extends(key, true, true),
3145                "macOS registers no Home/End extension"
3146            );
3147            assert!(!shift_home_end_extends(key, false, true));
3148            assert!(
3149                shift_home_end_extends(key, true, false),
3150                "Control+Shift+{key} must extend on Windows and Linux"
3151            );
3152            assert!(
3153                !shift_home_end_extends(key, false, false),
3154                "plain Shift+{key} must only move the cursor"
3155            );
3156        }
3157    }
3158
3159    /// Arrows and page keys never consult the Home/End gate: their forbidden
3160    /// extra chords are rejected earlier, by `exact_shift_navigation`.
3161    #[test]
3162    fn arrows_and_pages_skip_the_home_end_gate() {
3163        assert!(shift_home_end_extends("down", false, true));
3164        assert!(shift_home_end_extends("up", true, false));
3165        assert!(shift_home_end_extends("pagedown", false, false));
3166        assert!(shift_home_end_extends("pageup", true, true));
3167    }
3168
3169    #[test]
3170    fn home_end_registration_follows_the_platform_maps() {
3171        let none = gpui::Modifiers::none();
3172        let mut shift = none;
3173        shift.shift = true;
3174        let mut alt = none;
3175        alt.alt = true;
3176        let mut alt_shift = alt;
3177        alt_shift.shift = true;
3178        let mut control = none;
3179        control.control = true;
3180        let mut control_shift = control;
3181        control_shift.shift = true;
3182        let mut platform = none;
3183        platform.platform = true;
3184        let mut platform_shift = platform;
3185        platform_shift.shift = true;
3186        let mut function = none;
3187        function.function = true;
3188        let mut function_alt = function;
3189        function_alt.alt = true;
3190
3191        for modifiers in [none, shift, alt, alt_shift, function, function_alt] {
3192            assert!(
3193                home_end_registered(modifiers, true),
3194                "macOS must register {modifiers:?}"
3195            );
3196        }
3197        for modifiers in [control, control_shift, platform, platform_shift] {
3198            assert!(
3199                !home_end_registered(modifiers, true),
3200                "macOS must not register {modifiers:?}"
3201            );
3202        }
3203        for modifiers in [none, shift, control, control_shift, function] {
3204            assert!(
3205                home_end_registered(modifiers, false),
3206                "Windows and Linux must register {modifiers:?}"
3207            );
3208        }
3209        for modifiers in [alt, alt_shift, function_alt, platform, platform_shift] {
3210            assert!(
3211                !home_end_registered(modifiers, false),
3212                "Windows and Linux must not register {modifiers:?}"
3213            );
3214        }
3215    }
3216
3217    #[test]
3218    fn default_value_text_matches_pinned_select_value() {
3219        let select = Select::new(
3220            "select-default-text",
3221            vec![
3222                PickerItem::new("alpha", "Alpha"),
3223                PickerItem::new("beta", "Beta"),
3224                PickerItem::new("gamma", "Gamma"),
3225            ],
3226        )
3227        .selection_mode(SelectionMode::Multiple)
3228        .selected_keys(["alpha", "beta", "gamma"].map(SharedString::from));
3229
3230        assert_eq!(
3231            select.value_text_multiple(&select.selected_keys),
3232            "Alpha, Beta, and Gamma"
3233        );
3234        assert_eq!(
3235            format_selected_names(&["Alpha".into(), "Beta".into()]),
3236            "Alpha and Beta"
3237        );
3238        assert_eq!(
3239            Select::new("select-default-placeholder", Vec::new()).placeholder,
3240            "Select an item"
3241        );
3242    }
3243
3244    /// The keyed selection channel: keys, not labels, address items, so two
3245    /// items may share a label without aliasing each other, the reads walk
3246    /// the collection however the keys were picked or listed, and keys the
3247    /// collection does not hold resolve to nothing.
3248    #[test]
3249    fn keys_address_items_and_reads_walk_the_collection() {
3250        let keys = |names: &[&str]| -> Vec<SharedString> {
3251            names.iter().copied().map(SharedString::from).collect()
3252        };
3253        let items = vec![
3254            PickerItem::new("a", "Paris"),
3255            PickerItem::new("b", "London"),
3256            PickerItem::new("c", "Paris"),
3257        ];
3258        // Same label, distinct keys: each key resolves to its own item.
3259        let select = Select::new("select-keyed", items).value(Some(SharedString::from("c")));
3260        assert_eq!(
3261            select.value_text_single(&select.selected),
3262            "Paris",
3263            "the key must resolve to its own item, never a same-label sibling"
3264        );
3265        // A multiple selection reports in collection order however it was
3266        // listed, and an unknown key resolves to nothing.
3267        assert_eq!(
3268            in_collection_order(&keys(&["c", "a", "zz"]), &keys(&["a", "b", "c"])),
3269            keys(&["a", "c"]),
3270            "reads must walk the collection and drop unresolvable keys"
3271        );
3272        assert_eq!(
3273            resolved_keys(&select.items, &None, &keys(&["c", "b"])),
3274            keys(&["b", "c"]),
3275            "the resolved set follows the collection's row order"
3276        );
3277        assert_eq!(
3278            resolved_keys(&select.items, &Some(SharedString::from("gone")), &[]),
3279            Vec::<SharedString>::new(),
3280            "a key with no item resolves to no selection"
3281        );
3282    }
3283
3284    // The pinned `.select--secondary` hover fill is
3285    // `--select-trigger-bg-hover: var(--default-hover)` and the popup rows
3286    // fill with the full `bg-default`. The two accessors differ by one word
3287    // and the wrong one still looks plausible on screen, so the check is
3288    // mechanical.
3289    #[test]
3290    fn secondary_trigger_and_menu_rows_use_the_pinned_hover_tokens() {
3291        // Scan the implementation only; this test's own text names the
3292        // forbidden accessors.
3293        let source = include_str!("select.rs")
3294            .split("#[cfg(test)]")
3295            .next()
3296            .expect("the implementation section is always present");
3297        assert!(
3298            source.contains("FieldVariant::Secondary => colors.default.hover()"),
3299            "the secondary trigger hover must read `colors.default.hover()` \
3300             (pinned `--select-trigger-bg-hover: var(--default-hover)`)"
3301        );
3302        assert!(
3303            source
3304                .contains("let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);"),
3305            "the popup rows must hover the full `bg-default` \
3306             (pinned `.list-box-item:hover`)"
3307        );
3308        assert!(
3309            !source.contains("soft_hover()") && !source.contains("default.soft()"),
3310            "no select surface may hover a soft token"
3311        );
3312    }
3313
3314    #[test]
3315    fn trigger_hover_uses_the_pinned_smooth_transition() {
3316        let source = include_str!("select.rs")
3317            .split("#[cfg(test)]")
3318            .next()
3319            .expect("the implementation section is always present");
3320        assert!(
3321            source.contains("hover_fade_with_duration_and_easing_suppressed")
3322                && source.contains("Some(150)")
3323                && source.contains("HoverFadeEasing::EaseSmooth")
3324                && source.contains("|fill| fill.rounded(trigger_radius)"),
3325            "Select trigger hover must animate with the pinned 150ms smooth \
3326             transition and preserve the trigger radius"
3327        );
3328        assert!(
3329            source.contains("!field_box.is_bare && !self.is_invalid && !trigger_focused"),
3330            "invalid and focused triggers must keep ownership of their chrome"
3331        );
3332    }
3333
3334    #[test]
3335    fn option_rows_use_the_pinned_subtle_press_transition() {
3336        let source = include_str!("select.rs")
3337            .split("#[cfg(test)]")
3338            .next()
3339            .expect("the implementation section is always present");
3340        assert!(
3341            source.contains("crate::anim::pressed_with_background_ramp(")
3342                && source.contains("crate::anim::LIST_ITEM_PRESS")
3343                && source.contains("PRESSED_SCALE_SUBTLE")
3344                && source.contains("radius: util::soft_radius(cx)"),
3345            "Select options must use the shared 98% quart press ramp and keep their row radius"
3346        );
3347    }
3348}
3349
3350crate::util::impl_component_styled!(Select);