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