1use std::rc::Rc;
2
3use gpui::{
4 AccessibleAction, AnyElement, App, ElementId, FocusHandle, InteractiveElement as _,
5 IntoElement, KeyBinding, ParentElement, RenderOnce, Role, SharedString,
6 StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
7 prelude::FluentBuilder as _,
8};
9
10use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
11use crate::{StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
12
13const CONTEXT: &str = "Select";
14
15#[doc(hidden)]
16pub fn init(cx: &mut App) {
17 cx.bind_keys([
18 KeyBinding::new(
19 "space",
20 Confirm { secondary: false },
21 Some("Select && !Input"),
22 ),
23 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
24 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
25 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
26 KeyBinding::new(
27 if cx.platform_info().is_mac() {
28 "cmd-enter"
29 } else {
30 "ctrl-enter"
31 },
32 Confirm { secondary: true },
33 Some(CONTEXT),
34 ),
35 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
36 ]);
37}
38
39type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
40type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
41
42#[derive(IntoElement)]
53pub struct Select {
54 base: crate::ObservedElement<gpui::Stateful<gpui::Div>>,
55 open: bool,
56 disabled: bool,
57 focus_handle: Option<FocusHandle>,
58 content_focus_handle: Option<FocusHandle>,
59 accessibility_label: Option<SharedString>,
60 accessibility_value: Option<SharedString>,
61 style: StyleRefinement,
62 children: Vec<AnyElement>,
63 on_open_change: Option<OpenChangeHandler>,
64 key_context: &'static str,
65 on_dismiss: Option<ActionHandler>,
66 on_confirm: Option<ActionHandler>,
67}
68
69impl Select {
70 pub fn new(id: impl Into<ElementId>) -> Self {
71 Self {
72 base: div().id(id).test_support(),
73 open: false,
74 disabled: false,
75 focus_handle: None,
76 content_focus_handle: None,
77 accessibility_label: None,
78 accessibility_value: None,
79 style: StyleRefinement::default(),
80 children: Vec::new(),
81 on_open_change: None,
82 key_context: CONTEXT,
83 on_dismiss: None,
84 on_confirm: None,
85 }
86 }
87
88 pub fn open(mut self, open: bool) -> Self {
90 self.open = open;
91 self
92 }
93
94 pub fn disabled(mut self, disabled: bool) -> Self {
96 self.disabled = disabled;
97 self
98 }
99
100 pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
102 self.focus_handle = Some(focus_handle.clone());
103 self
104 }
105
106 pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
108 self.content_focus_handle = Some(focus_handle.clone());
109 self
110 }
111
112 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
114 self.accessibility_label = Some(label.into());
115 self
116 }
117
118 pub fn accessibility_value(mut self, value: impl Into<SharedString>) -> Self {
122 self.accessibility_value = Some(value.into());
123 self
124 }
125
126 pub fn on_open_change(
128 mut self,
129 handler: impl Fn(bool, &mut Window, &mut App) + 'static,
130 ) -> Self {
131 self.on_open_change = Some(Rc::new(handler));
132 self
133 }
134
135 #[doc(hidden)]
136 pub fn key_context(mut self, key_context: &'static str) -> Self {
137 self.key_context = key_context;
138 self
139 }
140
141 pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
148 self.on_dismiss = Some(Rc::new(handler));
149 self
150 }
151
152 pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
157 self.on_confirm = Some(Rc::new(handler));
158 self
159 }
160}
161
162impl Styled for Select {
163 fn style(&mut self) -> &mut StyleRefinement {
164 &mut self.style
165 }
166}
167
168impl ParentElement for Select {
169 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
170 self.children.extend(elements);
171 }
172}
173
174impl RenderOnce for Select {
175 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
176 let open = self.open;
177 let disabled = self.disabled;
178 let focus_handle = self.focus_handle;
179 let content_focus_handle = self.content_focus_handle;
180 let on_open_change = self.on_open_change;
181 let on_dismiss = self.on_dismiss;
182 let on_confirm = self.on_confirm;
183
184 let close: ActionHandler = Rc::new({
188 let on_open_change = on_open_change.clone();
189 let on_dismiss = on_dismiss.clone();
190 let focus_handle = focus_handle.clone();
191 move |window: &mut Window, cx: &mut App| {
192 if let Some(handler) = on_dismiss.as_ref() {
193 handler(window, cx);
194 }
195 if let Some(handler) = on_open_change.as_ref() {
196 handler(false, window, cx);
197 }
198 if let Some(handle) = focus_handle.as_ref() {
199 handle.focus(window, cx);
200 }
201 }
202 });
203
204 self.base
205 .role(Role::ComboBox)
206 .aria_expanded(open)
207 .when_some(self.accessibility_label, |this, label| {
208 this.aria_label(label)
209 })
210 .when_some(self.accessibility_value, |this, value| {
211 this.aria_value(value)
212 })
213 .key_context(self.key_context)
214 .when_some(
215 focus_handle.clone().filter(|_| !disabled),
216 |this, handle| this.track_focus(&handle.tab_stop(true)),
217 )
218 .when(!disabled, |this| {
219 let on_open_change = on_open_change.clone();
220 let content_focus_handle = content_focus_handle.clone();
221 let close = close.clone();
222
223 this.on_a11y_action(AccessibleAction::Click, move |_, window, cx| {
226 if open {
227 close(window, cx);
228 return;
229 }
230
231 if let Some(handler) = on_open_change.as_ref() {
232 handler(true, window, cx);
233 }
234 if let Some(handle) = content_focus_handle.as_ref() {
235 handle.focus(window, cx);
236 }
237 })
238 })
239 .on_action({
240 let on_open_change = on_open_change.clone();
241 let content_focus_handle = content_focus_handle.clone();
242 move |_: &SelectUp, window, cx| {
243 if disabled {
244 cx.propagate();
245 return;
246 }
247
248 if !open {
249 if let Some(handler) = on_open_change.as_ref() {
250 handler(true, window, cx);
251 }
252 }
253
254 if let Some(handle) = content_focus_handle.as_ref() {
255 handle.focus(window, cx);
256 }
257 cx.propagate();
258 }
259 })
260 .on_action({
261 let on_open_change = on_open_change.clone();
262 let content_focus_handle = content_focus_handle.clone();
263 move |_: &SelectDown, window, cx| {
264 if disabled {
265 cx.propagate();
266 return;
267 }
268
269 if !open {
270 if let Some(handler) = on_open_change.as_ref() {
271 handler(true, window, cx);
272 }
273 }
274
275 if let Some(handle) = content_focus_handle.as_ref() {
276 handle.focus(window, cx);
277 }
278 cx.propagate();
279 }
280 })
281 .on_action({
282 let on_open_change = on_open_change.clone();
283 move |_: &Confirm, window, cx| {
284 if disabled {
285 cx.propagate();
286 return;
287 }
288
289 if open {
290 if let Some(handler) = on_confirm.as_ref() {
291 handler(window, cx);
292 }
293 } else if let Some(handler) = on_open_change.as_ref() {
294 handler(true, window, cx);
295 }
296
297 if let Some(handle) = content_focus_handle.as_ref() {
298 handle.focus(window, cx);
299 }
300 }
301 })
302 .on_action(move |_: &Cancel, window, cx| {
303 if !open {
304 cx.propagate();
305 return;
306 }
307
308 cx.stop_propagation();
309 close(window, cx);
310 })
311 .children(self.children)
312 .refine_style(&self.style)
313 .a11y_disabled(disabled)
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::input::InputState;
321 use gpui::{
322 AppContext as _, Context, Element as _, Entity, Focusable, Render, TestAppContext,
323 VisualTestContext, accesskit, px,
324 };
325 use std::sync::{Arc, Mutex};
326
327 struct SelectHarness {
328 open: bool,
329 disabled: bool,
330 focus_handle: FocusHandle,
331 content_focus_handle: FocusHandle,
332 search_input: Option<Entity<InputState>>,
333 changes: Arc<Mutex<Vec<bool>>>,
334 parent_confirms: Arc<Mutex<usize>>,
335 confirmations: Arc<Mutex<usize>>,
336 closing: Arc<Mutex<Vec<&'static str>>>,
338 }
339
340 impl SelectHarness {
341 fn new(disabled: bool, cx: &mut Context<Self>) -> Self {
342 Self {
343 open: false,
344 disabled,
345 focus_handle: cx.focus_handle(),
346 content_focus_handle: cx.focus_handle(),
347 search_input: None,
348 changes: Arc::new(Mutex::new(Vec::new())),
349 closing: Arc::new(Mutex::new(Vec::new())),
350 parent_confirms: Arc::new(Mutex::new(0)),
351 confirmations: Arc::new(Mutex::new(0)),
352 }
353 }
354 }
355
356 impl Focusable for SelectHarness {
357 fn focus_handle(&self, _: &App) -> FocusHandle {
358 self.focus_handle.clone()
359 }
360 }
361
362 impl Render for SelectHarness {
363 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
364 let state = cx.entity();
365 let changes = self.changes.clone();
366 let opened = self.closing.clone();
367 let dismissed = self.closing.clone();
368 let confirmed = self.closing.clone();
369
370 let parent_confirms = self.parent_confirms.clone();
371 let confirmations = self.confirmations.clone();
372 div()
373 .on_action(move |_: &Confirm, _, _| *parent_confirms.lock().unwrap() += 1)
374 .child(
375 Select::new("select")
376 .open(self.open)
377 .disabled(self.disabled)
378 .focus_handle(&self.focus_handle)
379 .content_focus_handle(&self.content_focus_handle)
380 .on_open_change(move |open, _, cx| {
381 changes.lock().unwrap().push(open);
382 opened
383 .lock()
384 .unwrap()
385 .push(if open { "open" } else { "close" });
386 state.update(cx, |state, cx| {
387 state.open = open;
388 cx.notify();
389 });
390 })
391 .on_dismiss(move |_, _| dismissed.lock().unwrap().push("dismiss"))
392 .on_confirm(move |_, _| {
393 confirmed.lock().unwrap().push("confirm");
394 *confirmations.lock().unwrap() += 1;
395 })
396 .when_some(self.search_input.clone(), |this, input| {
397 this.child(crate::Input::new(&input))
398 })
399 .when(self.search_input.is_none(), |this| {
400 this.child(div().track_focus(&self.content_focus_handle).size(px(20.)))
401 }),
402 )
403 }
404 }
405
406 fn harness(
407 cx: &mut TestAppContext,
408 disabled: bool,
409 ) -> (&mut VisualTestContext, gpui::Entity<SelectHarness>) {
410 cx.update(crate::init);
411 let (state, cx) = cx.add_window_view(move |_, cx| SelectHarness::new(disabled, cx));
412 cx.update(|window, cx| {
413 state.focus_handle(cx).focus(window, cx);
414 window.draw(cx).clear(cx);
415 });
416 (cx, state)
417 }
418
419 #[gpui::test]
420 fn arrows_open_and_transfer_focus_to_content(cx: &mut TestAppContext) {
421 let (cx, state) = harness(cx, false);
422
423 cx.simulate_keystrokes("down");
424 cx.update(|window, cx| {
425 assert!(state.read(cx).open);
426 assert!(state.read(cx).content_focus_handle.is_focused(window));
427 });
428 assert_eq!(
429 &*state
430 .read_with(cx, |state, _| state.changes.clone())
431 .lock()
432 .unwrap(),
433 &[true]
434 );
435 }
436
437 #[gpui::test]
438 fn confirm_opens_a_closed_select(cx: &mut TestAppContext) {
439 let (cx, state) = harness(cx, false);
440
441 cx.simulate_keystrokes("enter");
442 cx.update(|window, cx| {
443 assert!(state.read(cx).open);
444 assert!(state.read(cx).content_focus_handle.is_focused(window));
445 });
446 }
447
448 #[gpui::test]
449 fn space_opens_and_confirms_a_non_searchable_select(cx: &mut TestAppContext) {
450 let (cx, state) = harness(cx, false);
451 cx.simulate_keystrokes("space");
452 cx.update(|window, cx| {
453 assert!(state.read(cx).open);
454 assert!(state.read(cx).content_focus_handle.is_focused(window));
455 window.draw(cx).clear(cx);
456 });
457 cx.simulate_keystrokes("space");
458 assert_eq!(
459 &*state
460 .read_with(cx, |state, _| state.closing.clone())
461 .lock()
462 .unwrap(),
463 &["open", "confirm"]
464 );
465 }
466
467 #[gpui::test]
468 fn space_in_a_select_search_input_remains_text(cx: &mut TestAppContext) {
469 let (cx, state) = harness(cx, false);
470 let input = cx.update(|window, cx| {
471 let input = cx.new(|cx| InputState::new(window, cx));
472 state.update(cx, |state, cx| {
473 state.content_focus_handle = input.focus_handle(cx);
474 state.search_input = Some(input.clone());
475 cx.notify();
476 });
477 window.draw(cx).clear(cx);
478 input
479 });
480 cx.simulate_keystrokes("space");
481 cx.update(|window, cx| {
482 assert!(state.read(cx).open);
483 assert!(input.focus_handle(cx).is_focused(window));
484 window.draw(cx).clear(cx);
485 });
486 cx.simulate_keystrokes("t w o space w o r d s");
487 assert_eq!(input.read_with(cx, |input, _| input.value()), "two words");
488 assert_eq!(
489 &*state
490 .read_with(cx, |state, _| state.closing.clone())
491 .lock()
492 .unwrap(),
493 &["open"]
494 );
495 assert!(state.read_with(cx, |state, _| state.open));
496 }
497
498 #[gpui::test]
499 fn escape_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
500 let (cx, state) = harness(cx, false);
501
502 cx.simulate_keystrokes("down escape");
503 cx.update(|window, cx| {
504 assert!(!state.read(cx).open);
505 assert!(state.read(cx).focus_handle.is_focused(window));
506 });
507 assert_eq!(
508 &*state
509 .read_with(cx, |state, _| state.changes.clone())
510 .lock()
511 .unwrap(),
512 &[true, false]
513 );
514 }
515
516 #[gpui::test]
528 fn every_close_dismisses_before_it_closes(cx: &mut TestAppContext) {
529 let (cx, state) = harness(cx, false);
530
531 cx.simulate_keystrokes("down escape");
532 assert_eq!(
533 &*state
534 .read_with(cx, |state, _| state.closing.clone())
535 .lock()
536 .unwrap(),
537 &["open", "dismiss", "close"]
538 );
539 }
540
541 #[gpui::test]
542 fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) {
543 let (cx, state) = harness(cx, true);
544
545 cx.simulate_keystrokes("down enter space");
546 assert!(!state.read_with(cx, |state, _| state.open));
547 assert!(
548 state
549 .read_with(cx, |state, _| state.changes.clone())
550 .lock()
551 .unwrap()
552 .is_empty()
553 );
554 }
555
556 #[gpui::test]
557 fn projects_application_owned_accessible_state(cx: &mut TestAppContext) {
558 let window = cx.add_empty_window();
559 window.update(|window, cx| {
560 let mut info = |select: Select| {
561 let mut node = accesskit::Node::new(Role::ComboBox);
562 select
563 .render(window, cx)
564 .into_element()
565 .write_a11y_info(&mut node);
566 node
567 };
568 let enabled = info(
569 Select::new("enabled")
570 .open(true)
571 .accessibility_label("Programming language")
572 .accessibility_value("Rust"),
573 );
574 let disabled = info(Select::new("disabled").open(true).disabled(true));
577
578 assert_eq!(enabled.label(), Some("Programming language"));
579 assert_eq!(enabled.value(), Some("Rust"));
580 assert_eq!(enabled.is_expanded(), Some(true));
581 assert_eq!(
582 disabled.is_expanded(),
583 Some(true),
584 "a disabled control still reports the state it is in"
585 );
586 assert!(enabled.supports_action(accesskit::Action::Click));
587 assert!(!disabled.supports_action(accesskit::Action::Click));
588 });
589 }
590
591 #[gpui::test]
592 fn confirm_is_consumed_when_opening_and_accepting(cx: &mut TestAppContext) {
593 let (cx, state) = harness(cx, false);
594 cx.simulate_keystrokes("enter");
595 cx.update(|window, cx| window.draw(cx).clear(cx));
596 cx.simulate_keystrokes("enter");
597 state.read_with(cx, |state, _| {
598 assert!(state.open);
599 assert_eq!(*state.confirmations.lock().unwrap(), 1);
600 assert_eq!(*state.parent_confirms.lock().unwrap(), 0);
601 });
602 }
603}