Skip to main content

gpui_base/
select.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ElementId, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding,
5    ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
6    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
7};
8
9use crate::StyledExt as _;
10use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
11
12const CONTEXT: &str = "Select";
13
14#[doc(hidden)]
15pub fn init(cx: &mut App) {
16    cx.bind_keys([
17        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
18        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
19        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
20        KeyBinding::new(
21            "secondary-enter",
22            Confirm { secondary: true },
23            Some(CONTEXT),
24        ),
25        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
26    ]);
27}
28
29type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
30type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
31
32/// An unstyled controlled select root.
33///
34/// Applications own the trigger and popup presentation, the option collection,
35/// and the selected value. This root owns combobox accessibility semantics,
36/// keyboard opening and dismissal, and focus transfer between the trigger and
37/// popup content.
38///
39/// GPUI marks the active option on the option element itself rather than on the
40/// container, so the application marks its highlighted option with
41/// `aria_active_descendant()`; this root cannot do it on the caller's behalf.
42#[derive(IntoElement)]
43pub struct Select {
44    id: ElementId,
45    open: bool,
46    disabled: bool,
47    focus_handle: Option<FocusHandle>,
48    content_focus_handle: Option<FocusHandle>,
49    accessibility_label: Option<SharedString>,
50    style: StyleRefinement,
51    children: Vec<AnyElement>,
52    on_open_change: Option<OpenChangeHandler>,
53    key_context: &'static str,
54    on_dismiss: Option<ActionHandler>,
55    on_confirm: Option<ActionHandler>,
56}
57
58impl Select {
59    pub fn new(id: impl Into<ElementId>) -> Self {
60        Self {
61            id: id.into(),
62            open: false,
63            disabled: false,
64            focus_handle: None,
65            content_focus_handle: None,
66            accessibility_label: None,
67            style: StyleRefinement::default(),
68            children: Vec::new(),
69            on_open_change: None,
70            key_context: CONTEXT,
71            on_dismiss: None,
72            on_confirm: None,
73        }
74    }
75
76    /// Sets the application-controlled open state.
77    pub fn open(mut self, open: bool) -> Self {
78        self.open = open;
79        self
80    }
81
82    /// Prevents keyboard interaction and removes the trigger from tab traversal.
83    pub fn disabled(mut self, disabled: bool) -> Self {
84        self.disabled = disabled;
85        self
86    }
87
88    /// Supplies the focus handle for the select trigger.
89    pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
90        self.focus_handle = Some(focus_handle.clone());
91        self
92    }
93
94    /// Supplies the focus handle that receives keyboard navigation while open.
95    pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
96        self.content_focus_handle = Some(focus_handle.clone());
97        self
98    }
99
100    /// Sets the accessible name exposed by the controlled root.
101    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
102        self.accessibility_label = Some(label.into());
103        self
104    }
105
106    /// Handles requests to update the controlled open state.
107    pub fn on_open_change(
108        mut self,
109        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
110    ) -> Self {
111        self.on_open_change = Some(Rc::new(handler));
112        self
113    }
114
115    #[doc(hidden)]
116    pub fn key_context(mut self, key_context: &'static str) -> Self {
117        self.key_context = key_context;
118        self
119    }
120
121    /// Handles a dismissal requested through the Cancel action.
122    ///
123    /// This runs before the controlled open state is asked to close, so a
124    /// caller that commits its pending value on dismissal can still read that
125    /// value here.
126    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
127        self.on_dismiss = Some(Rc::new(handler));
128        self
129    }
130
131    /// Handles the Confirm action while the select is open.
132    ///
133    /// Confirming a closed select opens it instead, so this never runs for
134    /// that case.
135    pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
136        self.on_confirm = Some(Rc::new(handler));
137        self
138    }
139}
140
141impl Styled for Select {
142    fn style(&mut self) -> &mut StyleRefinement {
143        &mut self.style
144    }
145}
146
147impl ParentElement for Select {
148    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
149        self.children.extend(elements);
150    }
151}
152
153impl RenderOnce for Select {
154    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
155        let open = self.open;
156        let disabled = self.disabled;
157        let focus_handle = self.focus_handle;
158        let content_focus_handle = self.content_focus_handle;
159        let on_open_change = self.on_open_change;
160        let on_dismiss = self.on_dismiss;
161        let on_confirm = self.on_confirm;
162
163        div()
164            .id(self.id)
165            .role(Role::ComboBox)
166            .aria_expanded(open)
167            .when_some(self.accessibility_label, |this, label| {
168                this.aria_label(label)
169            })
170            .key_context(self.key_context)
171            .when_some(
172                focus_handle.clone().filter(|_| !disabled),
173                |this, handle| this.track_focus(&handle.tab_stop(true)),
174            )
175            .on_action({
176                let on_open_change = on_open_change.clone();
177                let content_focus_handle = content_focus_handle.clone();
178                move |_: &SelectUp, window, cx| {
179                    if disabled {
180                        cx.propagate();
181                        return;
182                    }
183
184                    if !open {
185                        if let Some(handler) = on_open_change.as_ref() {
186                            handler(true, window, cx);
187                        }
188                    }
189
190                    if let Some(handle) = content_focus_handle.as_ref() {
191                        handle.focus(window, cx);
192                    }
193                    cx.propagate();
194                }
195            })
196            .on_action({
197                let on_open_change = on_open_change.clone();
198                let content_focus_handle = content_focus_handle.clone();
199                move |_: &SelectDown, window, cx| {
200                    if disabled {
201                        cx.propagate();
202                        return;
203                    }
204
205                    if !open {
206                        if let Some(handler) = on_open_change.as_ref() {
207                            handler(true, window, cx);
208                        }
209                    }
210
211                    if let Some(handle) = content_focus_handle.as_ref() {
212                        handle.focus(window, cx);
213                    }
214                    cx.propagate();
215                }
216            })
217            .on_action({
218                let on_open_change = on_open_change.clone();
219                move |_: &Confirm, window, cx| {
220                    if disabled {
221                        cx.propagate();
222                        return;
223                    }
224
225                    cx.propagate();
226                    if open {
227                        if let Some(handler) = on_confirm.as_ref() {
228                            handler(window, cx);
229                        }
230                    } else if let Some(handler) = on_open_change.as_ref() {
231                        handler(true, window, cx);
232                    }
233
234                    if let Some(handle) = content_focus_handle.as_ref() {
235                        handle.focus(window, cx);
236                    }
237                }
238            })
239            .on_action(move |_: &Cancel, window, cx| {
240                if !open {
241                    cx.propagate();
242                    return;
243                }
244
245                cx.stop_propagation();
246                if let Some(handler) = on_dismiss.as_ref() {
247                    handler(window, cx);
248                }
249                if let Some(handler) = on_open_change.as_ref() {
250                    handler(false, window, cx);
251                }
252                if let Some(handle) = focus_handle.as_ref() {
253                    handle.focus(window, cx);
254                }
255            })
256            .children(self.children)
257            .refine_style(&self.style)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use gpui::{Context, Focusable, Render, TestAppContext, VisualTestContext, px};
265    use std::sync::{Arc, Mutex};
266
267    struct SelectHarness {
268        open: bool,
269        disabled: bool,
270        focus_handle: FocusHandle,
271        content_focus_handle: FocusHandle,
272        changes: Arc<Mutex<Vec<bool>>>,
273    }
274
275    impl SelectHarness {
276        fn new(disabled: bool, cx: &mut Context<Self>) -> Self {
277            Self {
278                open: false,
279                disabled,
280                focus_handle: cx.focus_handle(),
281                content_focus_handle: cx.focus_handle(),
282                changes: Arc::new(Mutex::new(Vec::new())),
283            }
284        }
285    }
286
287    impl Focusable for SelectHarness {
288        fn focus_handle(&self, _: &App) -> FocusHandle {
289            self.focus_handle.clone()
290        }
291    }
292
293    impl Render for SelectHarness {
294        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
295            let state = cx.entity();
296            let changes = self.changes.clone();
297
298            Select::new("select")
299                .open(self.open)
300                .disabled(self.disabled)
301                .focus_handle(&self.focus_handle)
302                .content_focus_handle(&self.content_focus_handle)
303                .on_open_change(move |open, _, cx| {
304                    changes.lock().unwrap().push(open);
305                    state.update(cx, |state, cx| {
306                        state.open = open;
307                        cx.notify();
308                    });
309                })
310                .child(div().track_focus(&self.content_focus_handle).size(px(20.)))
311        }
312    }
313
314    fn harness(
315        cx: &mut TestAppContext,
316        disabled: bool,
317    ) -> (&mut VisualTestContext, gpui::Entity<SelectHarness>) {
318        cx.update(crate::init);
319        let (state, cx) = cx.add_window_view(move |_, cx| SelectHarness::new(disabled, cx));
320        cx.update(|window, cx| {
321            state.focus_handle(cx).focus(window, cx);
322            window.draw(cx).clear(cx);
323        });
324        (cx, state)
325    }
326
327    #[gpui::test]
328    fn arrows_open_and_transfer_focus_to_content(cx: &mut TestAppContext) {
329        let (cx, state) = harness(cx, false);
330
331        cx.simulate_keystrokes("down");
332        cx.update(|window, cx| {
333            assert!(state.read(cx).open);
334            assert!(state.read(cx).content_focus_handle.is_focused(window));
335        });
336        assert_eq!(
337            &*state
338                .read_with(cx, |state, _| state.changes.clone())
339                .lock()
340                .unwrap(),
341            &[true]
342        );
343    }
344
345    #[gpui::test]
346    fn confirm_opens_a_closed_select(cx: &mut TestAppContext) {
347        let (cx, state) = harness(cx, false);
348
349        cx.simulate_keystrokes("enter");
350        cx.update(|window, cx| {
351            assert!(state.read(cx).open);
352            assert!(state.read(cx).content_focus_handle.is_focused(window));
353        });
354    }
355
356    #[gpui::test]
357    fn escape_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
358        let (cx, state) = harness(cx, false);
359
360        cx.simulate_keystrokes("down escape");
361        cx.update(|window, cx| {
362            assert!(!state.read(cx).open);
363            assert!(state.read(cx).focus_handle.is_focused(window));
364        });
365        assert_eq!(
366            &*state
367                .read_with(cx, |state, _| state.changes.clone())
368                .lock()
369                .unwrap(),
370            &[true, false]
371        );
372    }
373
374    #[gpui::test]
375    fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) {
376        let (cx, state) = harness(cx, true);
377
378        cx.simulate_keystrokes("down enter");
379        assert!(!state.read_with(cx, |state, _| state.open));
380        assert!(
381            state
382                .read_with(cx, |state, _| state.changes.clone())
383                .lock()
384                .unwrap()
385                .is_empty()
386        );
387    }
388
389    #[test]
390    fn accepts_application_owned_accessible_label() {
391        let _ = Select::new("a11y-select")
392            .open(true)
393            .accessibility_label("Country");
394    }
395}