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#[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 pub fn open(mut self, open: bool) -> Self {
78 self.open = open;
79 self
80 }
81
82 pub fn disabled(mut self, disabled: bool) -> Self {
84 self.disabled = disabled;
85 self
86 }
87
88 pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
90 self.focus_handle = Some(focus_handle.clone());
91 self
92 }
93
94 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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
102 self.accessibility_label = Some(label.into());
103 self
104 }
105
106 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 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 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}