Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions crates/base/src/select.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::rc::Rc;

use gpui::{
AnyElement, App, ElementId, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding,
ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
AccessibleAction, AnyElement, App, ElementId, FocusHandle, InteractiveElement as _,
IntoElement, KeyBinding, ParentElement, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder as _,
};

use crate::StyledExt as _;
Expand Down Expand Up @@ -47,6 +48,7 @@ pub struct Select {
focus_handle: Option<FocusHandle>,
content_focus_handle: Option<FocusHandle>,
accessibility_label: Option<SharedString>,
accessibility_value: Option<SharedString>,
style: StyleRefinement,
children: Vec<AnyElement>,
on_open_change: Option<OpenChangeHandler>,
Expand All @@ -64,6 +66,7 @@ impl Select {
focus_handle: None,
content_focus_handle: None,
accessibility_label: None,
accessibility_value: None,
style: StyleRefinement::default(),
children: Vec::new(),
on_open_change: None,
Expand All @@ -79,7 +82,7 @@ impl Select {
self
}

/// Prevents keyboard interaction and removes the trigger from tab traversal.
/// Prevents keyboard and accessible activation and removes the trigger from tab traversal.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
Expand All @@ -103,6 +106,14 @@ impl Select {
self
}

/// Sets the committed value exposed by the controlled root.
///
/// Supply a readable selection title, not the current search query or cursor.
pub fn accessibility_value(mut self, value: impl Into<SharedString>) -> Self {
self.accessibility_value = Some(value.into());
self
}

/// Handles requests to update the controlled open state.
pub fn on_open_change(
mut self,
Expand Down Expand Up @@ -167,11 +178,36 @@ impl RenderOnce for Select {
.when_some(self.accessibility_label, |this, label| {
this.aria_label(label)
})
.when_some(self.accessibility_value, |this, value| {
this.aria_value(value)
})
.key_context(self.key_context)
.when_some(
focus_handle.clone().filter(|_| !disabled),
|this, handle| this.track_focus(&handle.tab_stop(true)),
)
.when(!disabled, |this| {
let on_open_change = on_open_change.clone();
let content_focus_handle = content_focus_handle.clone();
let focus_handle = focus_handle.clone();

// Platform adapters may flatten the trigger child.
// Expose activation on the semantic root itself.
this.on_a11y_action(AccessibleAction::Click, move |_, window, cx| {
if let Some(handler) = on_open_change.as_ref() {
handler(!open, window, cx);
}

let next_focus = if open {
focus_handle.as_ref()
} else {
content_focus_handle.as_ref()
};
if let Some(handle) = next_focus {
handle.focus(window, cx);
}
})
})
.on_action({
let on_open_change = on_open_change.clone();
let content_focus_handle = content_focus_handle.clone();
Expand Down Expand Up @@ -261,7 +297,9 @@ impl RenderOnce for Select {
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Context, Focusable, Render, TestAppContext, VisualTestContext, px};
use gpui::{
Context, Element as _, Focusable, Render, TestAppContext, VisualTestContext, accesskit, px,
};
use std::sync::{Arc, Mutex};

struct SelectHarness {
Expand Down Expand Up @@ -386,10 +424,32 @@ mod tests {
);
}

#[test]
fn accepts_application_owned_accessible_label() {
let _ = Select::new("a11y-select")
.open(true)
.accessibility_label("Country");
#[gpui::test]
fn projects_application_owned_accessible_state(cx: &mut TestAppContext) {
let window = cx.add_empty_window();
window.update(|window, cx| {
let mut info = |select: Select| {
let mut node = accesskit::Node::new(Role::ComboBox);
select
.render(window, cx)
.into_element()
.write_a11y_info(&mut node);
node
};
let enabled = info(
Select::new("enabled")
.open(true)
.accessibility_label("Programming language")
.accessibility_value("Rust"),
);
let disabled = info(Select::new("disabled").disabled(true));

assert_eq!(enabled.label(), Some("Programming language"));
assert_eq!(enabled.value(), Some("Rust"));
assert_eq!(enabled.is_expanded(), Some(true));
assert_eq!(disabled.is_expanded(), Some(false));
assert!(enabled.supports_action(accesskit::Action::Click));
assert!(!disabled.supports_action(accesskit::Action::Click));
});
}
}
62 changes: 61 additions & 1 deletion crates/component/src/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,22 @@ where
})
.child(title)
}

fn accessibility_value(&self) -> SharedString {
let Some((_, item)) = self.state.selection.first() else {
return self
.state
.placeholder
.clone()
.unwrap_or_else(|| t!("Select.placeholder").into());
};

if let Some(prefix) = self.title_prefix.as_ref() {
format!("{}{}", prefix, item.title()).into()
} else {
item.title()
}
}
}

impl<D> Render for SelectState<D>
Expand Down Expand Up @@ -765,6 +781,7 @@ where
});

let is_open = self.state.read(cx).state.open;
let accessibility_value = self.state.read(cx).accessibility_value();
let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
let open_state = self.state.clone();

Expand All @@ -776,6 +793,7 @@ where
})
.focus_handle(&focus_handle)
.content_focus_handle(&content_focus_handle)
.accessibility_value(accessibility_value)
.on_open_change(move |open, _, cx| {
open_state.update(cx, |state, cx| state.set_open(open, cx));
})
Expand All @@ -788,7 +806,7 @@ where

#[cfg(test)]
mod tests {
use gpui::{AppContext as _, TestAppContext};
use gpui::{AppContext as _, RenderOnce as _, TestAppContext};

use crate::{
IndexPath,
Expand Down Expand Up @@ -906,4 +924,46 @@ mod tests {
);
});
}

#[gpui::test]
fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
cx.update(crate::init);
let window = cx.add_empty_window();
window.update(|window, cx| {
let items = SearchableVec::new(vec!["Rust", "Go"]);
let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));

_ = Select::new(&state)
.placeholder("Choose a language")
.accessibility_label("Programming language")
.render(window, cx);
assert_eq!(state.read(cx).accessibility_value(), "Choose a language");

state.update(cx, |state, cx| {
state.set_selected_value(&"Rust", window, cx);
});
assert_eq!(state.read(cx).accessibility_value(), "Rust");

let list = state.read(cx).state.list.clone();
list.update(cx, |list, cx| list.set_query("Go", window, cx));
assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
// Filtering changes the available rows, not the committed value.
assert_eq!(state.read(cx).accessibility_value(), "Rust");

_ = Select::new(&state)
.placeholder("Choose a language")
.title_prefix("Language: ")
.render(window, cx);
assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");

state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
assert_eq!(state.read(cx).accessibility_value(), "Choose a language");

_ = Select::new(&state).render(window, cx);
assert_eq!(
state.read(cx).accessibility_value(),
rust_i18n::t!("Select.placeholder").to_string(),
);
});
}
}
1 change: 1 addition & 0 deletions crates/story/src/stories/select_story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ impl Render for SelectStory {
.items_center()
.child(
Select::new(&self.simple_select2)
.accessibility_label("Programming language")
.w(px(280.))
.with_size(self.size)
.disabled(self.disabled)
Expand Down
7 changes: 6 additions & 1 deletion website/base/primitives/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ The command above supplies application initialization, window creation, and shar

## Accessibility

Label the trigger, expose expanded/selected state, and support traversal, selection, Escape, and focus return.
Set `.accessibility_label(...)` on the controlled root and
`.accessibility_value(...)` to its committed selection, not a temporary search
cursor. The root exposes its expanded state and accessible activation. Activation
requests an open-state change and moves focus between the trigger and content.
Disabled controls do not expose activation. The styled `Select` supplies its
committed value automatically, falling back to its placeholder when unselected.

## Notes

Expand Down
15 changes: 15 additions & 0 deletions website/docs/components/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ Select::new(&state)
.placeholder("Select a language...")
```

### Accessibility

Give the control a name that stays the same when the selection changes:

```rust
Select::new(&state)
.accessibility_label("Programming language")
.placeholder("Choose a language")
```

The accessible value uses the committed item's `title()` and any `title_prefix`.
A custom `display_title()` remains visual presentation. Searching does not change
that committed value. With no selection, the accessible value uses the placeholder.
Enabled controls expose accessible activation to open or close the popup.

### Searchable

Use `searchable(true)` to enable search functionality within the dropdown.
Expand Down