Skip to content

select: expose committed values and accessible activation - #2973

Merged
huacnlee merged 4 commits into
longbridge:mainfrom
grishy:sg-select-accessibility
Sep 6, 2026
Merged

select: expose committed values and accessible activation#2973
huacnlee merged 4 commits into
longbridge:mainfrom
grishy:sg-select-accessibility

Conversation

@grishy

@grishy grishy commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

On macOS, Select's AXPopUpButton has no current value or AXPress action. Its trigger child can be flattened out of the accessibility tree, so the child's click handler is not enough.

This exposes activation on the enabled root and supplies the committed selection as its accessible value. It reuses the existing open-state callback and focus handles. The value includes the item's title and prefix, or the placeholder when unselected. Filtering does not change it. Base Select exposes .accessibility_value(...) for application-owned values.

Screenshots

These screenshots are only for context. They show two states of the same example, not a visual before/after change.

Go selected Searching for Rust
Select with Go committed and focus on the trigger Select open with Rust in the search field

Accessibility check

While searching for Rust, the root still reports the committed value Go. Captured from the macOS accessibility tree:

{
  "role": "AXPopUpButton",
  "title": "Programming language",
  "value": "Language: Go",
  "actions": ["AXPress"]
}

How to Test

MTL_HUD_ENABLED=1 ./script/run-story-macos Select

Scroll to Search and find Programming language in macOS Accessibility Inspector.

  1. Invoke AXPress, type Go, and press Enter. Expect Language: Go, a closed popup, and focus on the trigger.
  2. Reopen, clear the query, and type Rust. The accessible value must remain Language: Go.
  3. Invoke AXPress to close. Check focus returns. Also check Down opens and Escape closes.
  4. Inspect the Disabled example. It must have no AXPress action.
Automated checks
cargo test -p gpui-base -p gpui-component --lib --locked
cargo clippy -p gpui-base -p gpui-component -p gpui-component-story --all-targets --locked -- -D warnings

771 base and 419 component tests passed, along with strict Clippy and formatting. Regression coverage includes accessible metadata, disabled activation, filtering, prefixes, and placeholder fallback after clearing.

Windows and Linux runtime were not tested. Visual search preview and the existing disabled-root AXEnabled=1 reporting are outside this patch.

Checklist

  • Read CONTRIBUTING.md and followed the guidelines.
  • Reviewed the final patch, including the AI-assisted changes.
  • Manually tested the related Story examples on macOS.

AI assistance

I tested the original change in my application, then asked OpenAI's gpt-6-astra to extract it into a PR. The agent prepared the code, tests, verification scripts, and description, and ran the automated checks. I reviewed the extracted patch and manually tested the scenarios above on macOS using Accessibility Inspector.

Expose the committed value rather than the temporary search cursor.
Handle accessible activation on the semantic root because native accessibility
can flatten the painted trigger child.
@huacnlee
huacnlee merged commit 68a4acd into longbridge:main Sep 6, 2026
9 checks passed
huacnlee added a commit that referenced this pull request Sep 6, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants