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#[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 pub fn open(mut self, open: bool) -> Self {
81 self.open = open;
82 self
83 }
84
85 pub fn disabled(mut self, disabled: bool) -> Self {
87 self.disabled = disabled;
88 self
89 }
90
91 pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
93 self.focus_handle = Some(focus_handle.clone());
94 self
95 }
96
97 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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
105 self.accessibility_label = Some(label.into());
106 self
107 }
108
109 pub fn accessibility_value(mut self, value: impl Into<SharedString>) -> Self {
113 self.accessibility_value = Some(value.into());
114 self
115 }
116
117 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 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 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 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 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 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 #[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 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}