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