diff --git a/crates/base/src/select.rs b/crates/base/src/select.rs index 4dbd4fcac..cb6d0b405 100644 --- a/crates/base/src/select.rs +++ b/crates/base/src/select.rs @@ -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 _; @@ -47,6 +48,7 @@ pub struct Select { focus_handle: Option, content_focus_handle: Option, accessibility_label: Option, + accessibility_value: Option, style: StyleRefinement, children: Vec, on_open_change: Option, @@ -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, @@ -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 @@ -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) -> Self { + self.accessibility_value = Some(value.into()); + self + } + /// Handles requests to update the controlled open state. pub fn on_open_change( mut self, @@ -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(); @@ -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 { @@ -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)); + }); } } diff --git a/crates/component/src/select.rs b/crates/component/src/select.rs index 40467b0c0..c462c54c4 100644 --- a/crates/component/src/select.rs +++ b/crates/component/src/select.rs @@ -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 Render for SelectState @@ -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(); @@ -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)); }) @@ -788,7 +806,7 @@ where #[cfg(test)] mod tests { - use gpui::{AppContext as _, TestAppContext}; + use gpui::{AppContext as _, RenderOnce as _, TestAppContext}; use crate::{ IndexPath, @@ -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(), + ); + }); + } } diff --git a/crates/story/src/stories/select_story.rs b/crates/story/src/stories/select_story.rs index a0fb6a02f..c34643398 100644 --- a/crates/story/src/stories/select_story.rs +++ b/crates/story/src/stories/select_story.rs @@ -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) diff --git a/website/base/primitives/select.md b/website/base/primitives/select.md index 027e7eec2..794551c83 100644 --- a/website/base/primitives/select.md +++ b/website/base/primitives/select.md @@ -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 diff --git a/website/docs/components/select.md b/website/docs/components/select.md index 8cad59054..55a574893 100644 --- a/website/docs/components/select.md +++ b/website/docs/components/select.md @@ -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.