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