Skip to main content

gpui_component/
select.rs

1use gpui::{
2    AnyElement, App, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity, EventEmitter,
3    FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, Length, ParentElement, Render,
4    RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window,
5    deferred, div, prelude::FluentBuilder, px, rems,
6};
7use gpui_base::TestSupportExt as _;
8use rust_i18n::t;
9
10use crate::ThemeStyled as _;
11use crate::{
12    ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size,
13    StyleSized, StyledExt,
14    actions::Cancel,
15    h_flex,
16    input::{clear_button, input_style},
17    list::List,
18    searchable_list::{
19        SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
20    },
21    v_flex,
22};
23use gpui_base::{GlobalState, Select as BaseSelect};
24
25// MARK: Public re-exports for back-compat
26
27/// Re-exported for backward compatibility. New code should prefer [`SearchableGroup`].
28pub use crate::searchable_list::SearchableGroup as SelectGroup;
29/// Re-exported for backward compatibility. New code should prefer [`SearchableListDelegate`].
30pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
31/// Re-exported for backward compatibility. New code should prefer [`SearchableListItem`].
32pub use crate::searchable_list::SearchableListItem as SelectItem;
33/// Re-exported for backward compatibility. New code should prefer [`SearchableListItemElement`].
34pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
35/// Re-exported for backward compatibility.
36pub use crate::searchable_list::SearchableVec;
37
38#[derive(IntoElement)]
39pub struct Caret {
40    size: Size,
41    color: Option<Hsla>,
42}
43
44impl Caret {
45    /// Create a select caret sized for its trigger.
46    pub fn new(size: Size) -> Self {
47        Self { size, color: None }
48    }
49
50    /// Set the caret color.
51    pub fn text_color(mut self, color: Hsla) -> Self {
52        self.color = Some(color);
53        self
54    }
55}
56
57impl RenderOnce for Caret {
58    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
59        Icon::new(IconName::ChevronDown)
60            .with_size(match self.size {
61                Size::XSmall => Size::XSmall,
62                Size::Small => Size::Small,
63                _ => Size::Medium,
64            })
65            .when_some(self.color, |this, color| this.text_color(color))
66    }
67}
68
69/// Events emitted by [`SelectState`].
70pub enum SelectEvent<D: SearchableListDelegate + 'static>
71where
72    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
73{
74    Confirm(Option<<D::Item as SearchableListItem>::Value>),
75}
76
77// MARK: SelectOptions (builder only — applied to SearchableListState during render)
78
79struct SelectOptions {
80    style: StyleRefinement,
81    size: Size,
82    icon: Option<Icon>,
83    cleanable: bool,
84    placeholder: Option<SharedString>,
85    accessibility_label: Option<SharedString>,
86    title_prefix: Option<SharedString>,
87    search_placeholder: Option<SharedString>,
88    menu_width: Length,
89    menu_max_h: Length,
90    disabled: bool,
91    appearance: bool,
92    focus_ring_enabled: bool,
93}
94
95impl Default for SelectOptions {
96    fn default() -> Self {
97        Self {
98            style: StyleRefinement::default(),
99            size: Size::default(),
100            icon: None,
101            cleanable: false,
102            placeholder: None,
103            accessibility_label: None,
104            title_prefix: None,
105            menu_width: Length::Auto,
106            menu_max_h: rems(20.).into(),
107            disabled: false,
108            appearance: true,
109            focus_ring_enabled: true,
110            search_placeholder: None,
111        }
112    }
113}
114
115// MARK: SelectState
116
117/// State of the [`Select`] component.
118pub struct SelectState<D: SearchableListDelegate + 'static>
119where
120    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
121{
122    pub(crate) state: SearchableListState<D>,
123
124    // Select-specific fields
125    searchable: bool,
126    icon: Option<Icon>,
127    title_prefix: Option<SharedString>,
128    focus_ring_enabled: bool,
129}
130
131/// A Select element.
132#[derive(IntoElement)]
133pub struct Select<D: SearchableListDelegate + 'static>
134where
135    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
136{
137    id: ElementId,
138    state: Entity<SelectState<D>>,
139    options: SelectOptions,
140    empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
141}
142
143impl<D> SelectState<D>
144where
145    D: SearchableListDelegate + 'static,
146    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
147{
148    /// Create a new Select state.
149    pub fn new(
150        delegate: D,
151        selected_index: Option<IndexPath>,
152        window: &mut Window,
153        cx: &mut Context<Self>,
154    ) -> Self {
155        let weak = cx.entity().downgrade();
156        let weak_confirm = weak.clone();
157        let weak_cancel = weak.clone();
158        let weak_empty = weak;
159
160        let selected_indices = selected_index.into_iter().collect::<Vec<_>>();
161
162        let state = SearchableListState::new(
163            delegate,
164            selected_indices,
165            // on_confirm — commit the selection
166            move |selected_index, _secondary, window, cx| {
167                cx.defer_in(window, {
168                    let weak_confirm = weak_confirm.clone();
169                    move |list_state, window, cx| {
170                        let mut selection = weak_confirm
171                            .upgrade()
172                            .map(|e| e.read(cx).state.selection.clone())
173                            .unwrap_or_default();
174
175                        let changes = {
176                            let mut changes: Vec<SearchableListChange> = selection
177                                .iter()
178                                .map(|(ix, _)| SearchableListChange::Deselect { index: *ix })
179                                .collect();
180
181                            if let Some(ix) = selected_index {
182                                changes.push(SearchableListChange::Select { index: ix });
183                            }
184
185                            changes
186                        };
187
188                        // on_will_change is called directly — entity-handle access would
189                        // re-enter the ListState lock that defer_in holds for this callback.
190                        list_state
191                            .delegate_mut()
192                            .delegate
193                            .on_will_change(&mut selection, &changes);
194
195                        let new_selection = weak_confirm.update(cx, |this, cx| {
196                            this.state.selection = selection;
197
198                            let final_value =
199                                this.state.selection.first().map(|(_, i)| i.value().clone());
200
201                            cx.emit(SelectEvent::Confirm(final_value));
202                            cx.notify();
203                            this.set_open(false, cx);
204                            this.focus(window, cx);
205
206                            this.state.selection.clone()
207                        });
208
209                        // Sync snapshot and fire on_confirm directly — same re-entrancy guard.
210                        if let Ok(new_selection) = new_selection {
211                            list_state
212                                .delegate_mut()
213                                .update_selection_snapshot(new_selection.clone());
214                            list_state
215                                .delegate_mut()
216                                .delegate
217                                .on_confirm(&new_selection);
218                        }
219                    }
220                });
221            },
222            // on_cancel — restore cursor to committed index, close
223            move |_final_selected_index, window, cx| {
224                cx.defer_in(window, {
225                    let weak_cancel = weak_cancel.clone();
226                    move |list_state, window, cx| {
227                        let committed_ix = weak_cancel
228                            .upgrade()
229                            .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
230
231                        list_state.set_selected_index(committed_ix, window, cx);
232
233                        _ = weak_cancel.update(cx, |this, cx| {
234                            this.set_open(false, cx);
235                            this.focus(window, cx);
236                        });
237                    }
238                });
239            },
240            // on_render_empty
241            move |window, cx| {
242                if let Some(empty) = weak_empty
243                    .upgrade()
244                    .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
245                {
246                    empty
247                } else {
248                    h_flex()
249                        .justify_center()
250                        .py_6()
251                        .text_color(cx.theme().muted_foreground.opacity(0.6))
252                        .child(Icon::new(IconName::Inbox).size(px(28.)))
253                        .into_any_element()
254                }
255            },
256            Self::on_blur,
257            window,
258            cx,
259        );
260
261        Self {
262            state,
263            searchable: false,
264            icon: None,
265            title_prefix: None,
266            focus_ring_enabled: true,
267        }
268    }
269
270    /// Sets whether the dropdown menu is searchable, default is `false`.
271    ///
272    /// When `true`, a search input appears at the top of the dropdown menu.
273    pub fn searchable(mut self, searchable: bool) -> Self {
274        self.searchable = searchable;
275        self
276    }
277
278    /// Set the selected index for the select.
279    pub fn set_selected_index(
280        &mut self,
281        selected_index: Option<IndexPath>,
282        window: &mut Window,
283        cx: &mut Context<Self>,
284    ) {
285        self.state.list.update(cx, |list, cx| {
286            list._set_selected_index(selected_index, window, cx);
287        });
288
289        let item = selected_index
290            .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
291            .map(|i| i.clone());
292
293        self.state.selection = match (selected_index, item) {
294            (Some(ix), Some(item)) => vec![(ix, item)],
295            _ => vec![],
296        };
297        self.state.sync_snapshot(cx);
298    }
299
300    /// Set selected value for the select.
301    ///
302    /// Looks up the position from the delegate and sets the selected index accordingly.
303    /// Passes `None` when the value is not found.
304    ///
305    /// The delegate looks the value up in its matched items, so an active search query is
306    /// cleared first to get an index into the full item list.
307    pub fn set_selected_value(
308        &mut self,
309        selected_value: &<D::Item as SearchableListItem>::Value,
310        window: &mut Window,
311        cx: &mut Context<Self>,
312    ) {
313        self.state.clear_query(window, cx);
314
315        let selected_index = self
316            .state
317            .list
318            .read(cx)
319            .delegate()
320            .delegate
321            .position(selected_value);
322
323        self.set_selected_index(selected_index, window, cx);
324    }
325
326    /// Replace the delegate (item data) for the select state.
327    pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
328    where
329        D: SearchableListDelegate + 'static,
330    {
331        self.state.list.update(cx, |list, _| {
332            list.delegate_mut().delegate = items;
333        });
334    }
335
336    /// Get the current selected index.
337    pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
338        self.state.list.read(cx).selected_index()
339    }
340
341    /// Get the current selected value.
342    pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
343        self.state.selection.first().map(|(_, i)| i.value())
344    }
345
346    /// Focus the select trigger input.
347    pub fn focus(&self, window: &mut Window, cx: &mut App) {
348        self.state.focus_handle.focus(window, cx);
349    }
350
351    fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
352        if self.state.list.read(cx).is_focused(window, cx)
353            || self.state.focus_handle.is_focused(window)
354        {
355            return;
356        }
357
358        let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
359        if self.selected_index(cx) != committed_ix {
360            self.state.list.update(cx, |list, cx| {
361                list.set_selected_index(committed_ix, window, cx);
362            });
363        }
364
365        self.set_open(false, cx);
366        cx.notify();
367    }
368
369    fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
370        cx.stop_propagation();
371
372        self.set_open(!self.state.open, cx);
373
374        if self.state.open {
375            self.state.list.focus_handle(cx).focus(window, cx);
376        }
377
378        cx.notify();
379    }
380
381    fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
382        if !self.state.open {
383            cx.propagate();
384            return;
385        }
386
387        cx.stop_propagation();
388        self.set_open(false, cx);
389        self.focus(window, cx);
390        cx.notify();
391    }
392
393    fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
394        self.state.open = open;
395        self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
396
397        cx.notify();
398    }
399
400    fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
401        cx.stop_propagation();
402        self.set_selected_index(None, window, cx);
403        cx.emit(SelectEvent::Confirm(None));
404    }
405
406    fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
407        let default_title = div().text_color(cx.theme().muted_foreground).child(
408            self.state
409                .placeholder
410                .clone()
411                .unwrap_or_else(|| t!("Select.placeholder").into()),
412        );
413
414        let Some(selected_index) = self.selected_index(cx) else {
415            return default_title;
416        };
417
418        let Some(title) = self
419            .state
420            .list
421            .read(cx)
422            .delegate()
423            .delegate
424            .item(selected_index)
425            .map(|item| {
426                if let Some(el) = item.display_title() {
427                    el
428                } else if let Some(prefix) = self.title_prefix.as_ref() {
429                    format!("{}{}", prefix, item.title()).into_any_element()
430                } else {
431                    item.title().into_any_element()
432                }
433            })
434        else {
435            return default_title;
436        };
437
438        div()
439            .when(self.state.disabled, |this| {
440                this.text_color(cx.theme().muted_foreground)
441            })
442            .child(title)
443    }
444
445    fn accessibility_value(&self) -> SharedString {
446        let Some((_, item)) = self.state.selection.first() else {
447            return self
448                .state
449                .placeholder
450                .clone()
451                .unwrap_or_else(|| t!("Select.placeholder").into());
452        };
453
454        if let Some(prefix) = self.title_prefix.as_ref() {
455            format!("{}{}", prefix, item.title()).into()
456        } else {
457            item.title()
458        }
459    }
460}
461
462impl<D> Render for SelectState<D>
463where
464    D: SearchableListDelegate + 'static,
465    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
466{
467    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
468        let searchable = self.searchable;
469        let is_focused = self.state.focus_handle.is_focused(window);
470        let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
471        let bounds = self.state.bounds;
472        let allow_open = !(self.state.open || self.state.disabled);
473        let outline_visible = self.state.open || (is_focused && !self.state.disabled);
474
475        let (bg, fg) = input_style(self.state.disabled, cx);
476
477        self.state.list.update(cx, |list, cx| {
478            list.set_searchable(searchable, cx);
479            list.delegate_mut().size = self.state.size;
480        });
481
482        div().size_full().relative().child(
483            div()
484                .relative()
485                .on_prepaint({
486                    let state = cx.entity();
487                    move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
488                })
489                .child(
490                    div()
491                        .id("input")
492                        .test_support()
493                        .relative()
494                        .flex()
495                        .items_center()
496                        .justify_between()
497                        .border_1()
498                        .border_color(cx.theme().transparent)
499                        .when(self.state.appearance, |this| {
500                            this.bg(bg)
501                                .text_color(fg)
502                                .when(self.state.disabled, |this| this.opacity(0.5))
503                                .border_color(cx.theme().input)
504                                .rounded(cx.theme().radius)
505                        })
506                        .input_size(self.state.size)
507                        .input_text_size(self.state.size)
508                        .refine_style(&self.state.style)
509                        .when(outline_visible && self.state.appearance, |this| {
510                            this.border_1().border_color(cx.theme().ring)
511                        })
512                        .when(
513                            outline_visible && self.state.appearance && self.focus_ring_enabled,
514                            |this| this.focus_ring_style(window, cx),
515                        )
516                        .when(allow_open, |this| {
517                            this.on_click(cx.listener(Self::toggle_menu))
518                        })
519                        .child(
520                            h_flex()
521                                .id("inner")
522                                .w_full()
523                                .min_w_0()
524                                .overflow_hidden()
525                                .whitespace_nowrap()
526                                .items_center()
527                                .justify_between()
528                                .gap_1()
529                                .child(
530                                    div()
531                                        .id("title")
532                                        .flex_1()
533                                        .min_w_0()
534                                        .overflow_hidden()
535                                        .whitespace_nowrap()
536                                        .truncate()
537                                        .child(self.display_title(window, cx)),
538                                )
539                                .when(show_clean, |this| {
540                                    this.child(clear_button(cx).map(|this| {
541                                        if self.state.disabled {
542                                            this.disabled(true)
543                                        } else {
544                                            this.on_click(cx.listener(Self::clean))
545                                        }
546                                    }))
547                                })
548                                .when(!show_clean, |this| {
549                                    let icon = match self.icon.clone() {
550                                        Some(icon) => icon
551                                            .xsmall()
552                                            .text_color(cx.theme().muted_foreground)
553                                            .into_any_element(),
554                                        None => Caret::new(self.state.size)
555                                            .text_color(cx.theme().muted_foreground)
556                                            .into_any_element(),
557                                    };
558
559                                    this.child(icon)
560                                }),
561                        ),
562                )
563                .when(self.state.open, |this| {
564                    this.child(
565                        deferred(crate::popover::dropdown_popup(
566                            ("select-popup", cx.entity_id()),
567                            bounds,
568                            v_flex()
569                                .occlude()
570                                .map(|this| match self.state.menu_width {
571                                    Length::Auto => this.w(bounds.size.width + px(2.)),
572                                    Length::Definite(w) => this.w(w),
573                                })
574                                .popover_style(cx)
575                                .child(
576                                    List::new(&self.state.list)
577                                        .when_some(
578                                            self.state.search_placeholder.clone(),
579                                            |this, placeholder| {
580                                                this.search_placeholder(placeholder)
581                                            },
582                                        )
583                                        .with_size(self.state.size)
584                                        .max_h(self.state.menu_max_h)
585                                        .paddings(Edges::all(px(4.))),
586                                )
587                                .on_mouse_down_out(cx.listener(|this, _, window, cx| {
588                                    this.escape(&Cancel, window, cx);
589                                })),
590                            cx,
591                        ))
592                        .with_priority(gpui_base::POPUP_PRIORITY),
593                    )
594                }),
595        )
596    }
597}
598
599impl<D> Select<D>
600where
601    D: SearchableListDelegate + 'static,
602    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
603{
604    pub fn new(state: &Entity<SelectState<D>>) -> Self {
605        Self {
606            id: ("select", state.entity_id()).into(),
607            state: state.clone(),
608            options: SelectOptions::default(),
609            empty: None,
610        }
611    }
612
613    /// Sets an explicit identity for the select root.
614    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
615        self.id = id.into();
616        self
617    }
618
619    /// Set the width of the dropdown menu, default: `Length::Auto`.
620    pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
621        self.options.menu_width = width.into();
622        self
623    }
624
625    /// Set the max height of the dropdown menu, default: 20rem.
626    pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
627        self.options.menu_max_h = max_h.into();
628        self
629    }
630
631    /// Set the placeholder shown when no value is selected.
632    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
633        self.options.placeholder = Some(placeholder.into());
634        self
635    }
636
637    /// Set the name a screen reader announces for the select.
638    ///
639    /// The placeholder and selected value are not used as the accessible name,
640    /// because they describe the current value rather than the control itself.
641    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
642        self.options.accessibility_label = Some(label.into());
643        self
644    }
645
646    /// Override the trailing icon, replacing the default chevron.
647    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
648        self.options.icon = Some(icon.into());
649        self
650    }
651
652    /// Set a label prefix shown before the selected title in the trigger.
653    ///
654    /// e.g. `title_prefix("Country: ")` → "Country: United States"
655    pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
656        self.options.title_prefix = Some(prefix.into());
657        self
658    }
659
660    /// Show a clear button when a value is selected.
661    pub fn cleanable(mut self, cleanable: bool) -> Self {
662        self.options.cleanable = cleanable;
663        self
664    }
665
666    /// Set the placeholder text for the search input.
667    pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
668        self.options.search_placeholder = Some(placeholder.into());
669        self
670    }
671
672    /// Set the disabled state.
673    pub fn disabled(mut self, disabled: bool) -> Self {
674        self.options.disabled = disabled;
675        self
676    }
677
678    /// Set a custom closure that renders the empty-state element.
679    pub fn empty<E: IntoElement + 'static>(
680        mut self,
681        builder: impl Fn(&mut Window, &App) -> E + 'static,
682    ) -> Self {
683        self.empty = Some(Box::new(move |window, cx| {
684            builder(window, cx).into_any_element()
685        }));
686        self
687    }
688
689    /// Control whether the trigger shows a border and background (`true` by default).
690    pub fn appearance(mut self, appearance: bool) -> Self {
691        self.options.appearance = appearance;
692        self
693    }
694}
695
696impl<D> Sizable for Select<D>
697where
698    D: SearchableListDelegate + 'static,
699    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
700{
701    fn with_size(mut self, size: impl Into<Size>) -> Self {
702        self.options.size = size.into();
703        self
704    }
705}
706
707impl<D> crate::FocusableExt for Select<D>
708where
709    D: SearchableListDelegate + 'static,
710    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
711{
712    fn focus_ring(mut self, enabled: bool) -> Self {
713        self.options.focus_ring_enabled = enabled;
714        self
715    }
716
717    fn is_focus_ring_enabled(&self) -> bool {
718        self.options.focus_ring_enabled
719    }
720}
721
722impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
723where
724    D: SearchableListDelegate + 'static,
725    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
726{
727}
728
729impl<D> EventEmitter<DismissEvent> for SelectState<D>
730where
731    D: SearchableListDelegate + 'static,
732    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
733{
734}
735
736impl<D> Focusable for SelectState<D>
737where
738    D: SearchableListDelegate + 'static,
739    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
740{
741    fn focus_handle(&self, cx: &App) -> FocusHandle {
742        if self.state.open {
743            self.state.list.focus_handle(cx)
744        } else {
745            self.state.focus_handle.clone()
746        }
747    }
748}
749
750impl<D> Styled for Select<D>
751where
752    D: SearchableListDelegate + 'static,
753    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
754{
755    fn style(&mut self) -> &mut StyleRefinement {
756        &mut self.options.style
757    }
758}
759
760impl<D> RenderOnce for Select<D>
761where
762    D: SearchableListDelegate + 'static,
763    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
764{
765    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
766        let disabled = self.options.disabled;
767        let accessibility_label = self.options.accessibility_label.clone();
768        let focus_handle = self.state.read(cx).state.focus_handle.clone();
769        let empty = self.empty;
770        let opts = self.options;
771
772        self.state.update(cx, |this, _| {
773            this.state.style = opts.style;
774            this.state.size = opts.size;
775            this.state.cleanable = opts.cleanable;
776            this.state.placeholder = opts.placeholder;
777            this.state.search_placeholder = opts.search_placeholder;
778            this.state.menu_width = opts.menu_width;
779            this.state.menu_max_h = opts.menu_max_h;
780            this.state.disabled = opts.disabled;
781            this.state.appearance = opts.appearance;
782            this.focus_ring_enabled = opts.focus_ring_enabled;
783            this.icon = opts.icon;
784            this.title_prefix = opts.title_prefix;
785
786            if let Some(empty) = empty {
787                this.state.empty = Some(empty);
788            }
789        });
790
791        let is_open = self.state.read(cx).state.open;
792        let accessibility_value = self.state.read(cx).accessibility_value();
793        let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
794        let open_state = self.state.clone();
795
796        BaseSelect::new(self.id)
797            .open(is_open)
798            .disabled(disabled)
799            .when_some(accessibility_label, |this, label| {
800                this.accessibility_label(label)
801            })
802            .focus_handle(&focus_handle)
803            .content_focus_handle(&content_focus_handle)
804            .accessibility_value(accessibility_value)
805            .on_open_change(move |open, _, cx| {
806                open_state.update(cx, |state, cx| state.set_open(open, cx));
807            })
808            .size_full()
809            .child(self.state)
810    }
811}
812
813// MARK: Tests
814
815#[cfg(test)]
816mod tests {
817    use gpui::{AppContext as _, RenderOnce as _, TestAppContext};
818
819    use crate::{
820        IndexPath,
821        searchable_list::{SearchableListDelegate as _, SearchableVec},
822        select::{Select, SelectGroup, SelectState},
823    };
824
825    #[gpui::test]
826    fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
827        cx.update(crate::init);
828        let cx = cx.add_empty_window();
829        cx.update(|window, cx| {
830            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
831            let state = cx.new(|cx| SelectState::new(items, None, window, cx));
832
833            let plain = Select::new(&state).placeholder("Choose a language");
834            assert_eq!(plain.options.accessibility_label, None);
835            assert_eq!(
836                plain.options.placeholder.as_deref(),
837                Some("Choose a language")
838            );
839
840            let named = Select::new(&state)
841                .placeholder("Choose a language")
842                .accessibility_label("Programming language");
843            assert_eq!(
844                named.options.accessibility_label.as_deref(),
845                Some("Programming language")
846            );
847            assert_eq!(
848                named.options.placeholder.as_deref(),
849                Some("Choose a language"),
850                "an accessible name must not change what is drawn"
851            );
852        });
853    }
854
855    #[gpui::test]
856    fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
857        cx.update(crate::init);
858        let cx = cx.add_empty_window();
859        cx.update(|window, cx| {
860            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
861            let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
862
863            assert_eq!(
864                state.read(cx).selected_index(cx),
865                Some(IndexPath::new(1)),
866                "initial cursor should be seeded on ListState so display_title can read it",
867            );
868            assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
869        });
870    }
871
872    #[gpui::test]
873    fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
874        cx.update(crate::init);
875        let cx = cx.add_empty_window();
876        cx.update(|window, cx| {
877            let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
878            groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
879            groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
880
881            let initial = IndexPath::new(1).section(1);
882            let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
883
884            assert_eq!(state.read(cx).selected_index(cx), Some(initial));
885            assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
886        });
887    }
888
889    #[gpui::test]
890    fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
891        cx.update(crate::init);
892        let cx = cx.add_empty_window();
893        cx.update(|window, cx| {
894            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
895            let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
896            let list = state.read(cx).state.list.clone();
897
898            list.update(cx, |list, cx| list.set_query("Rust", window, cx));
899            assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
900
901            state.update(cx, |state, cx| {
902                state.set_selected_value(&"Go", window, cx);
903            });
904
905            assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
906            assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
907            assert_eq!(list.read(cx).query_input.read(cx).value(), "");
908        });
909    }
910
911    #[gpui::test]
912    fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
913        cx.update(crate::init);
914        let cx = cx.add_empty_window();
915        cx.update(|window, cx| {
916            let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
917            groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
918            groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
919
920            let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
921            let list = state.read(cx).state.list.clone();
922
923            list.update(cx, |list, cx| list.set_query("Blue", window, cx));
924            state.update(cx, |state, cx| {
925                state.set_selected_value(&"Banana", window, cx);
926            });
927
928            assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
929            assert_eq!(
930                state.read(cx).selected_index(cx),
931                Some(IndexPath::new(0).section(1)),
932            );
933        });
934    }
935
936    #[gpui::test]
937    fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
938        cx.update(crate::init);
939        let window = cx.add_empty_window();
940        window.update(|window, cx| {
941            let items = SearchableVec::new(vec!["Rust", "Go"]);
942            let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
943
944            _ = Select::new(&state)
945                .placeholder("Choose a language")
946                .accessibility_label("Programming language")
947                .render(window, cx);
948            assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
949
950            state.update(cx, |state, cx| {
951                state.set_selected_value(&"Rust", window, cx);
952            });
953            assert_eq!(state.read(cx).accessibility_value(), "Rust");
954
955            let list = state.read(cx).state.list.clone();
956            list.update(cx, |list, cx| list.set_query("Go", window, cx));
957            assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
958            // Filtering changes the available rows, not the committed value.
959            assert_eq!(state.read(cx).accessibility_value(), "Rust");
960
961            _ = Select::new(&state)
962                .placeholder("Choose a language")
963                .title_prefix("Language: ")
964                .render(window, cx);
965            assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");
966
967            state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
968            assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
969
970            _ = Select::new(&state).render(window, cx);
971            assert_eq!(
972                state.read(cx).accessibility_value(),
973                rust_i18n::t!("Select.placeholder").to_string(),
974            );
975        });
976    }
977}