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
25pub use crate::searchable_list::SearchableGroup as SelectGroup;
29pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
31pub use crate::searchable_list::SearchableListItem as SelectItem;
33pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
35pub use crate::searchable_list::SearchableVec;
37
38#[derive(IntoElement)]
39pub struct Caret {
40 size: Size,
41 color: Option<Hsla>,
42}
43
44impl Caret {
45 pub fn new(size: Size) -> Self {
47 Self { size, color: None }
48 }
49
50 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
69pub 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
77struct 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
115pub struct SelectState<D: SearchableListDelegate + 'static>
119where
120 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
121{
122 pub(crate) state: SearchableListState<D>,
123
124 searchable: bool,
126 icon: Option<Icon>,
127 title_prefix: Option<SharedString>,
128 focus_ring_enabled: bool,
129}
130
131#[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 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 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 list_state
191 .delegate_mut()
192 .delegate
193 .on_will_change(&mut selection, &changes);
194
195 let confirmed = 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.clone()));
202 cx.notify();
203 this.set_open(false, cx);
204 this.focus(window, cx);
205
206 (this.state.selection.clone(), final_value)
207 });
208
209 if let Ok((mut new_selection, final_value)) = confirmed {
215 if !list_state.query_input.read(cx).value().is_empty() {
216 list_state.set_query("", window, cx);
217 }
218
219 if let Some(ix) = final_value
220 .as_ref()
221 .and_then(|value| list_state.delegate().delegate.position(value))
222 {
223 list_state.set_selected_index(Some(ix), window, cx);
224 if let Some((slot, _)) = new_selection.first_mut() {
225 *slot = ix;
226 }
227 _ = weak_confirm.update(cx, |this, cx| {
228 if let Some((slot, _)) = this.state.selection.first_mut() {
229 *slot = ix;
230 }
231 cx.notify();
232 });
233 }
234
235 list_state
236 .delegate_mut()
237 .update_selection_snapshot(new_selection.clone());
238 list_state
239 .delegate_mut()
240 .delegate
241 .on_confirm(&new_selection);
242 }
243 }
244 });
245 },
246 move |_final_selected_index, window, cx| {
248 cx.defer_in(window, {
249 let weak_cancel = weak_cancel.clone();
250 move |list_state, window, cx| {
251 let committed_ix = weak_cancel
252 .upgrade()
253 .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
254
255 if !list_state.query_input.read(cx).value().is_empty() {
256 list_state.set_query("", window, cx);
257 }
258 list_state.set_selected_index(committed_ix, window, cx);
259
260 _ = weak_cancel.update(cx, |this, cx| {
261 this.set_open(false, cx);
262 this.focus(window, cx);
263 });
264 }
265 });
266 },
267 move |window, cx| {
269 if let Some(empty) = weak_empty
270 .upgrade()
271 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
272 {
273 empty
274 } else {
275 h_flex()
276 .justify_center()
277 .py_6()
278 .text_color(cx.theme().muted_foreground.opacity(0.6))
279 .child(Icon::new(IconName::Inbox).size(px(28.)))
280 .into_any_element()
281 }
282 },
283 Self::on_blur,
284 window,
285 cx,
286 );
287
288 Self {
289 state,
290 searchable: false,
291 icon: None,
292 title_prefix: None,
293 focus_ring_enabled: true,
294 }
295 }
296
297 pub fn searchable(mut self, searchable: bool) -> Self {
301 self.searchable = searchable;
302 self
303 }
304
305 pub fn set_selected_index(
307 &mut self,
308 selected_index: Option<IndexPath>,
309 window: &mut Window,
310 cx: &mut Context<Self>,
311 ) {
312 self.state.list.update(cx, |list, cx| {
313 list._set_selected_index(selected_index, window, cx);
314 });
315
316 let item = selected_index
317 .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
318 .map(|i| i.clone());
319
320 self.state.selection = match (selected_index, item) {
321 (Some(ix), Some(item)) => vec![(ix, item)],
322 _ => vec![],
323 };
324 self.state.sync_snapshot(cx);
325 }
326
327 pub fn set_selected_value(
335 &mut self,
336 selected_value: &<D::Item as SearchableListItem>::Value,
337 window: &mut Window,
338 cx: &mut Context<Self>,
339 ) {
340 self.state.clear_query(window, cx);
341
342 let selected_index = self
343 .state
344 .list
345 .read(cx)
346 .delegate()
347 .delegate
348 .position(selected_value);
349
350 self.set_selected_index(selected_index, window, cx);
351 }
352
353 pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
355 where
356 D: SearchableListDelegate + 'static,
357 {
358 self.state.list.update(cx, |list, _| {
359 list.delegate_mut().delegate = items;
360 });
361 }
362
363 pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
365 self.state.list.read(cx).selected_index()
366 }
367
368 pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
370 self.state.selection.first().map(|(_, i)| i.value())
371 }
372
373 pub fn focus(&self, window: &mut Window, cx: &mut App) {
375 self.state.focus_handle.focus(window, cx);
376 }
377
378 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
379 if self.state.list.read(cx).is_focused(window, cx)
380 || self.state.focus_handle.is_focused(window)
381 {
382 return;
383 }
384
385 self.clear_query_and_restore_cursor(window, cx);
386 self.set_open(false, cx);
387 cx.notify();
388 }
389
390 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
391 cx.stop_propagation();
392
393 self.set_open(!self.state.open, cx);
394
395 if self.state.open {
396 self.state.list.focus_handle(cx).focus(window, cx);
397 } else {
398 self.clear_query_and_restore_cursor(window, cx);
399 }
400
401 cx.notify();
402 }
403
404 fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
405 if !self.state.open {
406 cx.propagate();
407 return;
408 }
409
410 cx.stop_propagation();
411 self.clear_query_and_restore_cursor(window, cx);
412 self.set_open(false, cx);
413 self.focus(window, cx);
414 cx.notify();
415 }
416
417 fn clear_query_and_restore_cursor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
421 self.state.clear_query(window, cx);
422
423 let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
424 self.state.list.update(cx, |list, cx| {
425 if list.selected_index() != committed_ix {
426 list.set_selected_index(committed_ix, window, cx);
427 }
428 });
429 }
430
431 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
432 self.state.open = open;
433 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
434
435 cx.notify();
436 }
437
438 fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
439 cx.stop_propagation();
440 self.set_selected_index(None, window, cx);
441 cx.emit(SelectEvent::Confirm(None));
442 }
443
444 fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
445 let default_title = div().text_color(cx.theme().muted_foreground).child(
446 self.state
447 .placeholder
448 .clone()
449 .unwrap_or_else(|| t!("Select.placeholder").into()),
450 );
451
452 let Some(selected_index) = self.selected_index(cx) else {
453 return default_title;
454 };
455
456 let Some(title) = self
457 .state
458 .list
459 .read(cx)
460 .delegate()
461 .delegate
462 .item(selected_index)
463 .map(|item| {
464 if let Some(el) = item.display_title() {
465 el
466 } else if let Some(prefix) = self.title_prefix.as_ref() {
467 format!("{}{}", prefix, item.title()).into_any_element()
468 } else {
469 item.title().into_any_element()
470 }
471 })
472 else {
473 return default_title;
474 };
475
476 div()
477 .when(self.state.disabled, |this| {
478 this.text_color(cx.theme().muted_foreground)
479 })
480 .child(title)
481 }
482
483 fn accessibility_value(&self) -> SharedString {
484 let Some((_, item)) = self.state.selection.first() else {
485 return self
486 .state
487 .placeholder
488 .clone()
489 .unwrap_or_else(|| t!("Select.placeholder").into());
490 };
491
492 if let Some(prefix) = self.title_prefix.as_ref() {
493 format!("{}{}", prefix, item.title()).into()
494 } else {
495 item.title()
496 }
497 }
498}
499
500impl<D> Render for SelectState<D>
501where
502 D: SearchableListDelegate + 'static,
503 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
504{
505 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
506 let searchable = self.searchable;
507 let is_focused = self.state.focus_handle.is_focused(window);
508 let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
509 let bounds = self.state.bounds;
510 let allow_open = !(self.state.open || self.state.disabled);
511 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
512
513 let (bg, fg) = input_style(self.state.disabled, cx);
514
515 self.state.list.update(cx, |list, cx| {
516 list.set_searchable(searchable, cx);
517 list.delegate_mut().size = self.state.size;
518 });
519
520 div().size_full().relative().child(
521 div()
522 .relative()
523 .on_prepaint({
524 let state = cx.entity();
525 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
526 })
527 .child(
528 div()
529 .id("input")
530 .test_support()
531 .relative()
532 .flex()
533 .items_center()
534 .justify_between()
535 .border_1()
536 .border_color(cx.theme().transparent)
537 .when(self.state.appearance, |this| {
538 this.bg(bg)
539 .text_color(fg)
540 .when(self.state.disabled, |this| this.opacity(0.5))
541 .border_color(cx.theme().input)
542 .rounded(cx.theme().radius)
543 })
544 .input_size(self.state.size)
545 .input_text_size(self.state.size)
546 .refine_style(&self.state.style)
547 .when(outline_visible && self.state.appearance, |this| {
548 this.border_1().border_color(cx.theme().ring)
549 })
550 .when(
551 outline_visible && self.state.appearance && self.focus_ring_enabled,
552 |this| this.focus_ring_style(window, cx),
553 )
554 .when(allow_open, |this| {
555 this.on_click(cx.listener(Self::toggle_menu))
556 })
557 .child(
558 h_flex()
559 .id("inner")
560 .w_full()
561 .min_w_0()
562 .overflow_hidden()
563 .whitespace_nowrap()
564 .items_center()
565 .justify_between()
566 .gap_1()
567 .child(
568 div()
569 .id("title")
570 .flex_1()
571 .min_w_0()
572 .overflow_hidden()
573 .whitespace_nowrap()
574 .truncate()
575 .child(self.display_title(window, cx)),
576 )
577 .when(show_clean, |this| {
578 this.child(clear_button(cx).map(|this| {
579 if self.state.disabled {
580 this.disabled(true)
581 } else {
582 this.on_click(cx.listener(Self::clean))
583 }
584 }))
585 })
586 .when(!show_clean, |this| {
587 let icon = match self.icon.clone() {
588 Some(icon) => icon
589 .xsmall()
590 .text_color(cx.theme().muted_foreground)
591 .into_any_element(),
592 None => Caret::new(self.state.size)
593 .text_color(cx.theme().muted_foreground)
594 .into_any_element(),
595 };
596
597 this.child(icon)
598 }),
599 ),
600 )
601 .when(self.state.open, |this| {
602 this.child(
603 deferred(crate::popover::dropdown_popup(
604 ("select-popup", cx.entity_id()),
605 bounds,
606 v_flex()
607 .occlude()
608 .map(|this| match self.state.menu_width {
609 Length::Auto => this.w(bounds.size.width + px(2.)),
610 Length::Definite(w) => this.w(w),
611 })
612 .popover_style(cx)
613 .child(
614 List::new(&self.state.list)
615 .when_some(
616 self.state.search_placeholder.clone(),
617 |this, placeholder| {
618 this.search_placeholder(placeholder)
619 },
620 )
621 .with_size(self.state.size)
622 .max_h(self.state.menu_max_h)
623 .paddings(Edges::all(px(4.))),
624 )
625 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
626 this.escape(&Cancel, window, cx);
627 })),
628 cx,
629 ))
630 .with_priority(gpui_base::POPUP_PRIORITY),
631 )
632 }),
633 )
634 }
635}
636
637impl<D> Select<D>
638where
639 D: SearchableListDelegate + 'static,
640 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
641{
642 pub fn new(state: &Entity<SelectState<D>>) -> Self {
643 Self {
644 id: ("select", state.entity_id()).into(),
645 state: state.clone(),
646 options: SelectOptions::default(),
647 empty: None,
648 }
649 }
650
651 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
653 self.id = id.into();
654 self
655 }
656
657 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
659 self.options.menu_width = width.into();
660 self
661 }
662
663 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
665 self.options.menu_max_h = max_h.into();
666 self
667 }
668
669 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
671 self.options.placeholder = Some(placeholder.into());
672 self
673 }
674
675 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
680 self.options.accessibility_label = Some(label.into());
681 self
682 }
683
684 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
686 self.options.icon = Some(icon.into());
687 self
688 }
689
690 pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
694 self.options.title_prefix = Some(prefix.into());
695 self
696 }
697
698 pub fn cleanable(mut self, cleanable: bool) -> Self {
700 self.options.cleanable = cleanable;
701 self
702 }
703
704 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
706 self.options.search_placeholder = Some(placeholder.into());
707 self
708 }
709
710 pub fn disabled(mut self, disabled: bool) -> Self {
712 self.options.disabled = disabled;
713 self
714 }
715
716 pub fn empty<E: IntoElement + 'static>(
718 mut self,
719 builder: impl Fn(&mut Window, &App) -> E + 'static,
720 ) -> Self {
721 self.empty = Some(Box::new(move |window, cx| {
722 builder(window, cx).into_any_element()
723 }));
724 self
725 }
726
727 pub fn appearance(mut self, appearance: bool) -> Self {
729 self.options.appearance = appearance;
730 self
731 }
732}
733
734impl<D> Sizable for Select<D>
735where
736 D: SearchableListDelegate + 'static,
737 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
738{
739 fn with_size(mut self, size: impl Into<Size>) -> Self {
740 self.options.size = size.into();
741 self
742 }
743}
744
745impl<D> crate::FocusableExt for Select<D>
746where
747 D: SearchableListDelegate + 'static,
748 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
749{
750 fn focus_ring(mut self, enabled: bool) -> Self {
751 self.options.focus_ring_enabled = enabled;
752 self
753 }
754
755 fn is_focus_ring_enabled(&self) -> bool {
756 self.options.focus_ring_enabled
757 }
758}
759
760impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
761where
762 D: SearchableListDelegate + 'static,
763 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
764{
765}
766
767impl<D> EventEmitter<DismissEvent> for SelectState<D>
768where
769 D: SearchableListDelegate + 'static,
770 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
771{
772}
773
774impl<D> Focusable for SelectState<D>
775where
776 D: SearchableListDelegate + 'static,
777 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
778{
779 fn focus_handle(&self, cx: &App) -> FocusHandle {
780 if self.state.open {
781 self.state.list.focus_handle(cx)
782 } else {
783 self.state.focus_handle.clone()
784 }
785 }
786}
787
788impl<D> Styled for Select<D>
789where
790 D: SearchableListDelegate + 'static,
791 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
792{
793 fn style(&mut self) -> &mut StyleRefinement {
794 &mut self.options.style
795 }
796}
797
798impl<D> RenderOnce for Select<D>
799where
800 D: SearchableListDelegate + 'static,
801 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
802{
803 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
804 let disabled = self.options.disabled;
805 let accessibility_label = self.options.accessibility_label.clone();
806 let focus_handle = self.state.read(cx).state.focus_handle.clone();
807 let empty = self.empty;
808 let opts = self.options;
809
810 self.state.update(cx, |this, _| {
811 this.state.style = opts.style;
812 this.state.size = opts.size;
813 this.state.cleanable = opts.cleanable;
814 this.state.placeholder = opts.placeholder;
815 this.state.search_placeholder = opts.search_placeholder;
816 this.state.menu_width = opts.menu_width;
817 this.state.menu_max_h = opts.menu_max_h;
818 this.state.disabled = opts.disabled;
819 this.state.appearance = opts.appearance;
820 this.focus_ring_enabled = opts.focus_ring_enabled;
821 this.icon = opts.icon;
822 this.title_prefix = opts.title_prefix;
823
824 if let Some(empty) = empty {
825 this.state.empty = Some(empty);
826 }
827 });
828
829 let is_open = self.state.read(cx).state.open;
830 let accessibility_value = self.state.read(cx).accessibility_value();
831 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
832 let open_state = self.state.clone();
833
834 BaseSelect::new(self.id)
835 .open(is_open)
836 .disabled(disabled)
837 .when_some(accessibility_label, |this, label| {
838 this.accessibility_label(label)
839 })
840 .focus_handle(&focus_handle)
841 .content_focus_handle(&content_focus_handle)
842 .accessibility_value(accessibility_value)
843 .on_open_change(move |open, window, cx| {
844 open_state.update(cx, |state, cx| {
845 if !open {
846 state.clear_query_and_restore_cursor(window, cx);
847 }
848 state.set_open(open, cx);
849 });
850 })
851 .size_full()
852 .child(self.state)
853 }
854}
855
856#[cfg(test)]
859mod tests {
860 use gpui::{AppContext as _, RenderOnce as _, TestAppContext};
861
862 use crate::{
863 IndexPath,
864 searchable_list::{SearchableListDelegate as _, SearchableVec},
865 select::{Select, SelectGroup, SelectState},
866 };
867
868 #[gpui::test]
869 fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
870 cx.update(crate::init);
871 let cx = cx.add_empty_window();
872 cx.update(|window, cx| {
873 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
874 let state = cx.new(|cx| SelectState::new(items, None, window, cx));
875
876 let plain = Select::new(&state).placeholder("Choose a language");
877 assert_eq!(plain.options.accessibility_label, None);
878 assert_eq!(
879 plain.options.placeholder.as_deref(),
880 Some("Choose a language")
881 );
882
883 let named = Select::new(&state)
884 .placeholder("Choose a language")
885 .accessibility_label("Programming language");
886 assert_eq!(
887 named.options.accessibility_label.as_deref(),
888 Some("Programming language")
889 );
890 assert_eq!(
891 named.options.placeholder.as_deref(),
892 Some("Choose a language"),
893 "an accessible name must not change what is drawn"
894 );
895 });
896 }
897
898 #[gpui::test]
899 fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
900 cx.update(crate::init);
901 let cx = cx.add_empty_window();
902 cx.update(|window, cx| {
903 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
904 let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
905
906 assert_eq!(
907 state.read(cx).selected_index(cx),
908 Some(IndexPath::new(1)),
909 "initial cursor should be seeded on ListState so display_title can read it",
910 );
911 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
912 });
913 }
914
915 #[gpui::test]
916 fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
917 cx.update(crate::init);
918 let cx = cx.add_empty_window();
919 cx.update(|window, cx| {
920 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
921 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
922 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
923
924 let initial = IndexPath::new(1).section(1);
925 let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
926
927 assert_eq!(state.read(cx).selected_index(cx), Some(initial));
928 assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
929 });
930 }
931
932 #[gpui::test]
933 fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
934 cx.update(crate::init);
935 let cx = cx.add_empty_window();
936 cx.update(|window, cx| {
937 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
938 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
939 let list = state.read(cx).state.list.clone();
940
941 list.update(cx, |list, cx| list.set_query("Rust", window, cx));
942 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
943
944 state.update(cx, |state, cx| {
945 state.set_selected_value(&"Go", window, cx);
946 });
947
948 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
949 assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
950 assert_eq!(list.read(cx).query_input.read(cx).value(), "");
951 });
952 }
953
954 #[gpui::test]
955 fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
956 cx.update(crate::init);
957 let cx = cx.add_empty_window();
958 cx.update(|window, cx| {
959 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
960 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
961 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
962
963 let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
964 let list = state.read(cx).state.list.clone();
965
966 list.update(cx, |list, cx| list.set_query("Blue", window, cx));
967 state.update(cx, |state, cx| {
968 state.set_selected_value(&"Banana", window, cx);
969 });
970
971 assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
972 assert_eq!(
973 state.read(cx).selected_index(cx),
974 Some(IndexPath::new(0).section(1)),
975 );
976 });
977 }
978
979 #[gpui::test]
980 fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
981 cx.update(crate::init);
982 let window = cx.add_empty_window();
983 window.update(|window, cx| {
984 let items = SearchableVec::new(vec!["Rust", "Go"]);
985 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
986
987 _ = Select::new(&state)
988 .placeholder("Choose a language")
989 .accessibility_label("Programming language")
990 .render(window, cx);
991 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
992
993 state.update(cx, |state, cx| {
994 state.set_selected_value(&"Rust", window, cx);
995 });
996 assert_eq!(state.read(cx).accessibility_value(), "Rust");
997
998 let list = state.read(cx).state.list.clone();
999 list.update(cx, |list, cx| list.set_query("Go", window, cx));
1000 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
1001 assert_eq!(state.read(cx).accessibility_value(), "Rust");
1003
1004 _ = Select::new(&state)
1005 .placeholder("Choose a language")
1006 .title_prefix("Language: ")
1007 .render(window, cx);
1008 assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");
1009
1010 state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
1011 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
1012
1013 _ = Select::new(&state).render(window, cx);
1014 assert_eq!(
1015 state.read(cx).accessibility_value(),
1016 rust_i18n::t!("Select.placeholder").to_string(),
1017 );
1018 });
1019 }
1020}