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 _};
12
13const CONTEXT: &str = "Select";
14
15#[doc(hidden)]
16pub fn init(cx: &mut App) {
17    cx.bind_keys([
18        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
19        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
20        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
21        KeyBinding::new(
22            "secondary-enter",
23            Confirm { secondary: true },
24            Some(CONTEXT),
25        ),
26        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
27    ]);
28}
29
30type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
31type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
32
33/// An unstyled controlled select root.
34///
35/// Applications own the trigger and popup presentation, the option collection,
36/// and the selected value. This root owns combobox accessibility semantics,
37/// keyboard opening and dismissal, and focus transfer between the trigger and
38/// popup content.
39///
40/// GPUI marks the active option on the option element itself rather than on the
41/// container, so the application marks its highlighted option with
42/// `aria_active_descendant()`; this root cannot do it on the caller's behalf.
43#[derive(IntoElement)]
44pub struct Select {
45    base: crate::ObservedElement<gpui::Stateful<gpui::Div>>,
46    open: bool,
47    disabled: bool,
48    focus_handle: Option<FocusHandle>,
49    content_focus_handle: Option<FocusHandle>,
50    accessibility_label: Option<SharedString>,
51    accessibility_value: Option<SharedString>,
52    style: StyleRefinement,
53    children: Vec<AnyElement>,
54    on_open_change: Option<OpenChangeHandler>,
55    key_context: &'static str,
56    on_dismiss: Option<ActionHandler>,
57    on_confirm: Option<ActionHandler>,
58}
59
60impl Select {
61    pub fn new(id: impl Into<ElementId>) -> Self {
62        Self {
63            base: div().id(id).test_support(),
64            open: false,
65            disabled: false,
66            focus_handle: None,
67            content_focus_handle: None,
68            accessibility_label: None,
69            accessibility_value: None,
70            style: StyleRefinement::default(),
71            children: Vec::new(),
72            on_open_change: None,
73            key_context: CONTEXT,
74            on_dismiss: None,
75            on_confirm: None,
76        }
77    }
78
79    /// Sets the application-controlled open state.
80    pub fn open(mut self, open: bool) -> Self {
81        self.open = open;
82        self
83    }
84
85    /// Prevents keyboard and accessible activation and removes the trigger from tab traversal.
86    pub fn disabled(mut self, disabled: bool) -> Self {
87        self.disabled = disabled;
88        self
89    }
90
91    /// Supplies the focus handle for the select trigger.
92    pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
93        self.focus_handle = Some(focus_handle.clone());
94        self
95    }
96
97    /// Supplies the focus handle that receives keyboard navigation while open.
98    pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
99        self.content_focus_handle = Some(focus_handle.clone());
100        self
101    }
102
103    /// Sets the accessible name exposed by the controlled root.
104    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
105        self.accessibility_label = Some(label.into());
106        self
107    }
108
109    /// Sets the committed value exposed by the controlled root.
110    ///
111    /// Supply a readable selection title, not the current search query or cursor.
112    pub fn accessibility_value(mut self, value: impl Into<SharedString>) -> Self {
113        self.accessibility_value = Some(value.into());
114        self
115    }
116
117    /// Handles requests to update the controlled open state.
118    pub fn on_open_change(
119        mut self,
120        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
121    ) -> Self {
122        self.on_open_change = Some(Rc::new(handler));
123        self
124    }
125
126    #[doc(hidden)]
127    pub fn key_context(mut self, key_context: &'static str) -> Self {
128        self.key_context = key_context;
129        self
130    }
131
132    /// Handles a dismissal, however it was requested: the Cancel action, or
133    /// the accessible activation that closes an open control.
134    ///
135    /// This runs before the controlled open state is asked to close, so a
136    /// caller that commits its pending value on dismissal can still read that
137    /// value here.
138    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
139        self.on_dismiss = Some(Rc::new(handler));
140        self
141    }
142
143    /// Handles the Confirm action while the select is open.
144    ///
145    /// Confirming a closed select opens it instead, so this never runs for
146    /// that case.
147    pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
148        self.on_confirm = Some(Rc::new(handler));
149        self
150    }
151}
152
153impl Styled for Select {
154    fn style(&mut self) -> &mut StyleRefinement {
155        &mut self.style
156    }
157}
158
159impl ParentElement for Select {
160    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
161        self.children.extend(elements);
162    }
163}
164
165impl RenderOnce for Select {
166    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
167        let open = self.open;
168        let disabled = self.disabled;
169        let focus_handle = self.focus_handle;
170        let content_focus_handle = self.content_focus_handle;
171        let on_open_change = self.on_open_change;
172        let on_dismiss = self.on_dismiss;
173        let on_confirm = self.on_confirm;
174
175        // Every way of closing runs the same steps. A caller that tracks
176        // dismissal has to see one however the popup was closed, and the
177        // accessible activation closes exactly what Escape closes.
178        let close: ActionHandler = Rc::new({
179            let on_open_change = on_open_change.clone();
180            let on_dismiss = on_dismiss.clone();
181            let focus_handle = focus_handle.clone();
182            move |window: &mut Window, cx: &mut App| {
183                if let Some(handler) = on_dismiss.as_ref() {
184                    handler(window, cx);
185                }
186                if let Some(handler) = on_open_change.as_ref() {
187                    handler(false, window, cx);
188                }
189                if let Some(handle) = focus_handle.as_ref() {
190                    handle.focus(window, cx);
191                }
192            }
193        });
194
195        self.base
196            .role(Role::ComboBox)
197            .aria_expanded(open)
198            .when_some(self.accessibility_label, |this, label| {
199                this.aria_label(label)
200            })
201            .when_some(self.accessibility_value, |this, value| {
202                this.aria_value(value)
203            })
204            .key_context(self.key_context)
205            .when_some(
206                focus_handle.clone().filter(|_| !disabled),
207                |this, handle| this.track_focus(&handle.tab_stop(true)),
208            )
209            .when(!disabled, |this| {
210                let on_open_change = on_open_change.clone();
211                let content_focus_handle = content_focus_handle.clone();
212                let close = close.clone();
213
214                // Platform adapters may flatten the trigger child.
215                // Expose activation on the semantic root itself.
216                this.on_a11y_action(AccessibleAction::Click, move |_, window, cx| {
217                    if open {
218                        close(window, cx);
219                        return;
220                    }
221
222                    if let Some(handler) = on_open_change.as_ref() {
223                        handler(true, window, cx);
224                    }
225                    if let Some(handle) = content_focus_handle.as_ref() {
226                        handle.focus(window, cx);
227                    }
228                })
229            })
230            .on_action({
231                let on_open_change = on_open_change.clone();
232                let content_focus_handle = content_focus_handle.clone();
233                move |_: &SelectUp, window, cx| {
234                    if disabled {
235                        cx.propagate();
236                        return;
237                    }
238
239                    if !open {
240                        if let Some(handler) = on_open_change.as_ref() {
241                            handler(true, window, cx);
242                        }
243                    }
244
245                    if let Some(handle) = content_focus_handle.as_ref() {
246                        handle.focus(window, cx);
247                    }
248                    cx.propagate();
249                }
250            })
251            .on_action({
252                let on_open_change = on_open_change.clone();
253                let content_focus_handle = content_focus_handle.clone();
254                move |_: &SelectDown, window, cx| {
255                    if disabled {
256                        cx.propagate();
257                        return;
258                    }
259
260                    if !open {
261                        if let Some(handler) = on_open_change.as_ref() {
262                            handler(true, window, cx);
263                        }
264                    }
265
266                    if let Some(handle) = content_focus_handle.as_ref() {
267                        handle.focus(window, cx);
268                    }
269                    cx.propagate();
270                }
271            })
272            .on_action({
273                let on_open_change = on_open_change.clone();
274                move |_: &Confirm, window, cx| {
275                    if disabled {
276                        cx.propagate();
277                        return;
278                    }
279
280                    cx.propagate();
281                    if open {
282                        if let Some(handler) = on_confirm.as_ref() {
283                            handler(window, cx);
284                        }
285                    } else if let Some(handler) = on_open_change.as_ref() {
286                        handler(true, window, cx);
287                    }
288
289                    if let Some(handle) = content_focus_handle.as_ref() {
290                        handle.focus(window, cx);
291                    }
292                }
293            })
294            .on_action(move |_: &Cancel, window, cx| {
295                if !open {
296                    cx.propagate();
297                    return;
298                }
299
300                cx.stop_propagation();
301                close(window, cx);
302            })
303            .children(self.children)
304            .refine_style(&self.style)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use gpui::{
312        Context, Element as _, Focusable, Render, TestAppContext, VisualTestContext, accesskit, px,
313    };
314    use std::sync::{Arc, Mutex};
315
316    struct SelectHarness {
317        open: bool,
318        disabled: bool,
319        focus_handle: FocusHandle,
320        content_focus_handle: FocusHandle,
321        changes: Arc<Mutex<Vec<bool>>>,
322        /// Every step of a close, in the order it ran.
323        closing: Arc<Mutex<Vec<&'static str>>>,
324    }
325
326    impl SelectHarness {
327        fn new(disabled: bool, cx: &mut Context<Self>) -> Self {
328            Self {
329                open: false,
330                disabled,
331                focus_handle: cx.focus_handle(),
332                content_focus_handle: cx.focus_handle(),
333                changes: Arc::new(Mutex::new(Vec::new())),
334                closing: Arc::new(Mutex::new(Vec::new())),
335            }
336        }
337    }
338
339    impl Focusable for SelectHarness {
340        fn focus_handle(&self, _: &App) -> FocusHandle {
341            self.focus_handle.clone()
342        }
343    }
344
345    impl Render for SelectHarness {
346        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
347            let state = cx.entity();
348            let changes = self.changes.clone();
349            let opened = self.closing.clone();
350            let dismissed = self.closing.clone();
351
352            Select::new("select")
353                .open(self.open)
354                .disabled(self.disabled)
355                .focus_handle(&self.focus_handle)
356                .content_focus_handle(&self.content_focus_handle)
357                .on_open_change(move |open, _, cx| {
358                    changes.lock().unwrap().push(open);
359                    opened
360                        .lock()
361                        .unwrap()
362                        .push(if open { "open" } else { "close" });
363                    state.update(cx, |state, cx| {
364                        state.open = open;
365                        cx.notify();
366                    });
367                })
368                .on_dismiss(move |_, _| dismissed.lock().unwrap().push("dismiss"))
369                .child(div().track_focus(&self.content_focus_handle).size(px(20.)))
370        }
371    }
372
373    fn harness(
374        cx: &mut TestAppContext,
375        disabled: bool,
376    ) -> (&mut VisualTestContext, gpui::Entity<SelectHarness>) {
377        cx.update(crate::init);
378        let (state, cx) = cx.add_window_view(move |_, cx| SelectHarness::new(disabled, cx));
379        cx.update(|window, cx| {
380            state.focus_handle(cx).focus(window, cx);
381            window.draw(cx).clear(cx);
382        });
383        (cx, state)
384    }
385
386    #[gpui::test]
387    fn arrows_open_and_transfer_focus_to_content(cx: &mut TestAppContext) {
388        let (cx, state) = harness(cx, false);
389
390        cx.simulate_keystrokes("down");
391        cx.update(|window, cx| {
392            assert!(state.read(cx).open);
393            assert!(state.read(cx).content_focus_handle.is_focused(window));
394        });
395        assert_eq!(
396            &*state
397                .read_with(cx, |state, _| state.changes.clone())
398                .lock()
399                .unwrap(),
400            &[true]
401        );
402    }
403
404    #[gpui::test]
405    fn confirm_opens_a_closed_select(cx: &mut TestAppContext) {
406        let (cx, state) = harness(cx, false);
407
408        cx.simulate_keystrokes("enter");
409        cx.update(|window, cx| {
410            assert!(state.read(cx).open);
411            assert!(state.read(cx).content_focus_handle.is_focused(window));
412        });
413    }
414
415    #[gpui::test]
416    fn escape_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
417        let (cx, state) = harness(cx, false);
418
419        cx.simulate_keystrokes("down escape");
420        cx.update(|window, cx| {
421            assert!(!state.read(cx).open);
422            assert!(state.read(cx).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, false]
430        );
431    }
432
433    /// Closing runs `on_dismiss`, and runs it before the open state is asked
434    /// to close, so a caller that commits a pending value on dismissal can
435    /// still read that value.
436    ///
437    /// Every close shares one path, which is the point: the accessible
438    /// activation used to close by calling `on_open_change` alone, so a
439    /// consumer wiring `on_dismiss` — `crates/shell` forwards it to JS as
440    /// `onDismiss` — saw Escape but not a screen reader pressing the same
441    /// control. GPUI exposes no way to dispatch an accessibility action in a
442    /// test (`Window::handle_a11y_action` is `pub(crate)`), so this covers the
443    /// shared path through the route a test can reach.
444    #[gpui::test]
445    fn every_close_dismisses_before_it_closes(cx: &mut TestAppContext) {
446        let (cx, state) = harness(cx, false);
447
448        cx.simulate_keystrokes("down escape");
449        assert_eq!(
450            &*state
451                .read_with(cx, |state, _| state.closing.clone())
452                .lock()
453                .unwrap(),
454            &["open", "dismiss", "close"]
455        );
456    }
457
458    #[gpui::test]
459    fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) {
460        let (cx, state) = harness(cx, true);
461
462        cx.simulate_keystrokes("down enter");
463        assert!(!state.read_with(cx, |state, _| state.open));
464        assert!(
465            state
466                .read_with(cx, |state, _| state.changes.clone())
467                .lock()
468                .unwrap()
469                .is_empty()
470        );
471    }
472
473    #[gpui::test]
474    fn projects_application_owned_accessible_state(cx: &mut TestAppContext) {
475        let window = cx.add_empty_window();
476        window.update(|window, cx| {
477            let mut info = |select: Select| {
478                let mut node = accesskit::Node::new(Role::ComboBox);
479                select
480                    .render(window, cx)
481                    .into_element()
482                    .write_a11y_info(&mut node);
483                node
484            };
485            let enabled = info(
486                Select::new("enabled")
487                    .open(true)
488                    .accessibility_label("Programming language")
489                    .accessibility_value("Rust"),
490            );
491            // Open, so the expanded assertion below says something about
492            // `disabled` rather than about the default open state.
493            let disabled = info(Select::new("disabled").open(true).disabled(true));
494
495            assert_eq!(enabled.label(), Some("Programming language"));
496            assert_eq!(enabled.value(), Some("Rust"));
497            assert_eq!(enabled.is_expanded(), Some(true));
498            assert_eq!(
499                disabled.is_expanded(),
500                Some(true),
501                "a disabled control still reports the state it is in"
502            );
503            assert!(enabled.supports_action(accesskit::Action::Click));
504            assert!(!disabled.supports_action(accesskit::Action::Click));
505        });
506    }
507}