Skip to main content

gpui_base/
select.rs

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/// An unstyled controlled select root.
43///
44/// Applications own the trigger and popup presentation, the option collection,
45/// and the selected value. This root owns combobox accessibility semantics,
46/// keyboard opening and dismissal, and focus transfer between the trigger and
47/// popup content.
48///
49/// GPUI marks the active option on the option element itself rather than on the
50/// container, so the application marks its highlighted option with
51/// `aria_active_descendant()`; this root cannot do it on the caller's behalf.
52#[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    /// Sets the application-controlled open state.
89    pub fn open(mut self, open: bool) -> Self {
90        self.open = open;
91        self
92    }
93
94    /// Prevents keyboard and accessible activation and removes the trigger from tab traversal.
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = disabled;
97        self
98    }
99
100    /// Supplies the focus handle for the select trigger.
101    pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
102        self.focus_handle = Some(focus_handle.clone());
103        self
104    }
105
106    /// Supplies the focus handle that receives keyboard navigation while open.
107    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    /// Sets the accessible name exposed by the controlled root.
113    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
114        self.accessibility_label = Some(label.into());
115        self
116    }
117
118    /// Sets the committed value exposed by the controlled root.
119    ///
120    /// Supply a readable selection title, not the current search query or cursor.
121    pub fn accessibility_value(mut self, value: impl Into<SharedString>) -> Self {
122        self.accessibility_value = Some(value.into());
123        self
124    }
125
126    /// Handles requests to update the controlled open state.
127    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    /// Handles a dismissal, however it was requested: the Cancel action, or
142    /// the accessible activation that closes an open control.
143    ///
144    /// This runs before the controlled open state is asked to close, so a
145    /// caller that commits its pending value on dismissal can still read that
146    /// value here.
147    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    /// Handles the Confirm action while the select is open.
153    ///
154    /// Confirming a closed select opens it instead, so this never runs for
155    /// that case.
156    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        // Every way of closing runs the same steps. A caller that tracks
185        // dismissal has to see one however the popup was closed, and the
186        // accessible activation closes exactly what Escape closes.
187        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                // Platform adapters may flatten the trigger child.
224                // Expose activation on the semantic root itself.
225                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        /// Every step of a close, in the order it ran.
337        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    /// Closing runs `on_dismiss`, and runs it before the open state is asked
517    /// to close, so a caller that commits a pending value on dismissal can
518    /// still read that value.
519    ///
520    /// Every close shares one path, which is the point: the accessible
521    /// activation used to close by calling `on_open_change` alone, so a
522    /// consumer wiring `on_dismiss` — `crates/shell` forwards it to JS as
523    /// `onDismiss` — saw Escape but not a screen reader pressing the same
524    /// control. GPUI exposes no way to dispatch an accessibility action in a
525    /// test (`Window::handle_a11y_action` is `pub(crate)`), so this covers the
526    /// shared path through the route a test can reach.
527    #[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            // Open, so the expanded assertion below says something about
575            // `disabled` rather than about the default open state.
576            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}