Skip to content

Commit 5f5ba08

Browse files
huacnleeclaude
andauthored
select: run on_dismiss however the popup closes (#2984)
Follow-up to #2973, which is already merged. That PR is good work — this closes the gaps a post-merge review turned up. ## `on_dismiss` was skipped by the accessible close `Select`'s `Cancel` handler runs `on_dismiss` before asking the controlled open state to close. The `on_a11y_action(Click)` path added in #2973 called `on_open_change(false)` alone, so a consumer wiring `on_dismiss` saw Escape and an outside click but *not* a screen-reader user pressing the same control to close. That is not hypothetical: `crates/shell/src/materialize/components/select.rs:240` forwards `on_dismiss` to JS as `onDismiss`, so a shell app silently lost the callback on exactly the interaction #2973 was written to support. Both paths now share one `close` closure, so they cannot drift apart again: ```rust let close: ActionHandler = Rc::new({ /* on_dismiss → on_open_change(false) → focus trigger */ }); ``` ## Also in this patch - **zh-CN docs.** #2973 changed `website/docs/components/select.md` and `website/base/primitives/select.md`; both zh-CN counterparts exist and were left behind. Synced. - **`SearchableListItem` docs.** The rule that the accessible value reports `title()` while `display_title()` stays presentational was documented only on the site. Moved onto the trait, where someone implementing `display_title` will actually read it. - **A test assertion that asserted nothing.** `projects_application_owned_accessible_state` checked `disabled.is_expanded() == Some(false)` on a `Select` that was never opened, so it passed for a reason unrelated to `disabled`. It is now opened, and asserts a disabled control still reports the state it is in. ## Testing ``` cargo test -p gpui-base -p gpui-component --lib # 777 + 418 pass cargo clippy -p gpui-base -p gpui-component --all-targets --locked # clean cargo fmt --all --check # clean ``` GPUI exposes no way to dispatch an accessibility action from a test — `Window::handle_a11y_action` is `pub(crate)` — so `every_close_dismisses_before_it_closes` covers the shared close path through Escape, the route a test can reach. I confirmed it fails (`["open", "close"]` vs `["open", "dismiss", "close"]`) when the `on_dismiss` call is removed again. ## Left open deliberately Two findings from the same review are **not** in this PR, because both are judgement calls rather than defects: 1. **The accessible value and the drawn trigger disagree during a search.** `accessibility_value()` reads the committed `state.selection`; `display_title()` reads the list cursor, which `set_query` clears. Verified on `main`: ``` after set_selected_value("Rust") then set_query("Go"): a11y value = "Rust" trigger draws = <placeholder> ``` I think the committed value is right for assistive technology and the *display* is the bug — a Select with a committed selection should not visually revert to its placeholder while you type in the popup's search box. That is pre-existing behaviour, so fixing it is a separate change. Worth deciding whether to fix it or stop documenting the mismatch as intended. 2. **Placeholder as the accessible value.** With nothing selected the AX value becomes the placeholder, so AT reads "Programming language, Choose a language". GPUI has `aria_placeholder` (`gpui-pre-0.3.2`, `src/elements/div.rs:1391`), which is the semantically correct slot — that would also keep name, placeholder and value as three separate things, the way `an_explicit_accessibility_label_does_not_replace_the_placeholder` already insists. ## AI assistance The post-merge review that found these and this patch were produced with Claude Code. I reviewed the diff and the checks above before opening this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EC7fHTjQ6nGPKqq4WdryL8 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4475eeb commit 5f5ba08

4 files changed

Lines changed: 100 additions & 22 deletions

File tree

crates/base/src/select.rs

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ impl Select {
129129
self
130130
}
131131

132-
/// Handles a dismissal requested through the Cancel action.
132+
/// Handles a dismissal, however it was requested: the Cancel action, or
133+
/// the accessible activation that closes an open control.
133134
///
134135
/// This runs before the controlled open state is asked to close, so a
135136
/// caller that commits its pending value on dismissal can still read that
@@ -171,6 +172,26 @@ impl RenderOnce for Select {
171172
let on_dismiss = self.on_dismiss;
172173
let on_confirm = self.on_confirm;
173174

175+
// Every way of closing runs the same steps. A caller that tracks
176+
// dismissal has to see one however the popup was closed, and the
177+
// accessible activation closes exactly what Escape closes.
178+
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+
174195
div()
175196
.id(self.id)
176197
.role(Role::ComboBox)
@@ -189,21 +210,20 @@ impl RenderOnce for Select {
189210
.when(!disabled, |this| {
190211
let on_open_change = on_open_change.clone();
191212
let content_focus_handle = content_focus_handle.clone();
192-
let focus_handle = focus_handle.clone();
213+
let close = close.clone();
193214

194215
// Platform adapters may flatten the trigger child.
195216
// Expose activation on the semantic root itself.
196217
this.on_a11y_action(AccessibleAction::Click, move |_, window, cx| {
197-
if let Some(handler) = on_open_change.as_ref() {
198-
handler(!open, window, cx);
218+
if open {
219+
close(window, cx);
220+
return;
199221
}
200222

201-
let next_focus = if open {
202-
focus_handle.as_ref()
203-
} else {
204-
content_focus_handle.as_ref()
205-
};
206-
if let Some(handle) = next_focus {
223+
if let Some(handler) = on_open_change.as_ref() {
224+
handler(true, window, cx);
225+
}
226+
if let Some(handle) = content_focus_handle.as_ref() {
207227
handle.focus(window, cx);
208228
}
209229
})
@@ -279,15 +299,7 @@ impl RenderOnce for Select {
279299
}
280300

281301
cx.stop_propagation();
282-
if let Some(handler) = on_dismiss.as_ref() {
283-
handler(window, cx);
284-
}
285-
if let Some(handler) = on_open_change.as_ref() {
286-
handler(false, window, cx);
287-
}
288-
if let Some(handle) = focus_handle.as_ref() {
289-
handle.focus(window, cx);
290-
}
302+
close(window, cx);
291303
})
292304
.children(self.children)
293305
.refine_style(&self.style)
@@ -308,6 +320,8 @@ mod tests {
308320
focus_handle: FocusHandle,
309321
content_focus_handle: FocusHandle,
310322
changes: Arc<Mutex<Vec<bool>>>,
323+
/// Every step of a close, in the order it ran.
324+
closing: Arc<Mutex<Vec<&'static str>>>,
311325
}
312326

313327
impl SelectHarness {
@@ -318,6 +332,7 @@ mod tests {
318332
focus_handle: cx.focus_handle(),
319333
content_focus_handle: cx.focus_handle(),
320334
changes: Arc::new(Mutex::new(Vec::new())),
335+
closing: Arc::new(Mutex::new(Vec::new())),
321336
}
322337
}
323338
}
@@ -332,6 +347,8 @@ mod tests {
332347
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
333348
let state = cx.entity();
334349
let changes = self.changes.clone();
350+
let opened = self.closing.clone();
351+
let dismissed = self.closing.clone();
335352

336353
Select::new("select")
337354
.open(self.open)
@@ -340,11 +357,16 @@ mod tests {
340357
.content_focus_handle(&self.content_focus_handle)
341358
.on_open_change(move |open, _, cx| {
342359
changes.lock().unwrap().push(open);
360+
opened
361+
.lock()
362+
.unwrap()
363+
.push(if open { "open" } else { "close" });
343364
state.update(cx, |state, cx| {
344365
state.open = open;
345366
cx.notify();
346367
});
347368
})
369+
.on_dismiss(move |_, _| dismissed.lock().unwrap().push("dismiss"))
348370
.child(div().track_focus(&self.content_focus_handle).size(px(20.)))
349371
}
350372
}
@@ -409,6 +431,31 @@ mod tests {
409431
);
410432
}
411433

434+
/// Closing runs `on_dismiss`, and runs it before the open state is asked
435+
/// to close, so a caller that commits a pending value on dismissal can
436+
/// still read that value.
437+
///
438+
/// Every close shares one path, which is the point: the accessible
439+
/// activation used to close by calling `on_open_change` alone, so a
440+
/// consumer wiring `on_dismiss` — `crates/shell` forwards it to JS as
441+
/// `onDismiss` — saw Escape but not a screen reader pressing the same
442+
/// control. GPUI exposes no way to dispatch an accessibility action in a
443+
/// test (`Window::handle_a11y_action` is `pub(crate)`), so this covers the
444+
/// shared path through the route a test can reach.
445+
#[gpui::test]
446+
fn every_close_dismisses_before_it_closes(cx: &mut TestAppContext) {
447+
let (cx, state) = harness(cx, false);
448+
449+
cx.simulate_keystrokes("down escape");
450+
assert_eq!(
451+
&*state
452+
.read_with(cx, |state, _| state.closing.clone())
453+
.lock()
454+
.unwrap(),
455+
&["open", "dismiss", "close"]
456+
);
457+
}
458+
412459
#[gpui::test]
413460
fn disabled_select_is_not_keyboard_interactive(cx: &mut TestAppContext) {
414461
let (cx, state) = harness(cx, true);
@@ -442,12 +489,18 @@ mod tests {
442489
.accessibility_label("Programming language")
443490
.accessibility_value("Rust"),
444491
);
445-
let disabled = info(Select::new("disabled").disabled(true));
492+
// Open, so the expanded assertion below says something about
493+
// `disabled` rather than about the default open state.
494+
let disabled = info(Select::new("disabled").open(true).disabled(true));
446495

447496
assert_eq!(enabled.label(), Some("Programming language"));
448497
assert_eq!(enabled.value(), Some("Rust"));
449498
assert_eq!(enabled.is_expanded(), Some(true));
450-
assert_eq!(disabled.is_expanded(), Some(false));
499+
assert_eq!(
500+
disabled.is_expanded(),
501+
Some(true),
502+
"a disabled control still reports the state it is in"
503+
);
451504
assert!(enabled.supports_action(accesskit::Action::Click));
452505
assert!(!disabled.supports_action(accesskit::Action::Click));
453506
});

crates/component/src/searchable_list/delegate.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ pub trait SearchableListItem: Clone {
99
type Value: Clone + PartialEq;
1010

1111
/// Short display label shown in the dropdown row and in the trigger by default.
12+
///
13+
/// This is also what assistive technology reads as the committed value, so
14+
/// it has to stand on its own as text even when [`Self::display_title`]
15+
/// draws something richer.
1216
fn title(&self) -> SharedString;
1317

1418
/// Override the trigger display element (e.g. "Country (US)" instead of just "United States").
1519
///
1620
/// Returns `None` to fall back to `title()`.
21+
///
22+
/// This is presentation only. An element is not text, so the accessible
23+
/// value keeps reporting [`Self::title`]; if the two would read
24+
/// differently, put the meaning a listener needs in `title()`.
1725
fn display_title(&self) -> Option<AnyElement> {
1826
None
1927
}

website/zh-CN/base/primitives/select.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ use gpui_kit::base::{Select};
4040

4141
## 可访问性
4242

43-
提供标签,暴露当前值,并保留上下键、Enter、Escape 与类型检索。
43+
在受控根节点上设置 `.accessibility_label(...)`,并把 `.accessibility_value(...)`
44+
设为已提交的选中项,而不是临时的搜索游标。根节点会暴露展开状态与可访问的激活操作。
45+
激活会请求切换展开状态,并在 trigger 与内容之间移动焦点。禁用的控件不暴露激活操作。
46+
带样式的 `Select` 会自动提供已提交的值,未选中时回退到 placeholder。
4447

4548
## 注意事项
4649

website/zh-CN/docs/components/select.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ Select::new(&state)
6565
.placeholder("Select a language...")
6666
```
6767

68+
### 可访问性
69+
70+
给控件一个不随选中项变化的名称:
71+
72+
```rust
73+
Select::new(&state)
74+
.accessibility_label("Programming language")
75+
.placeholder("Choose a language")
76+
```
77+
78+
可访问值取自已提交选项的 `title()` 以及 `title_prefix`。自定义的 `display_title()`
79+
仍然只用于视觉呈现。搜索不会改变这个已提交的值。未选中时,可访问值使用 placeholder。
80+
启用状态的控件会暴露可访问的激活操作,用于打开或关闭弹层。
81+
6882
### 可搜索
6983

7084
启用 `searchable(true)` 后,下拉菜单中会出现搜索能力:

0 commit comments

Comments
 (0)