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