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