Skip to content

fix: make the select popup ours to style, not the OS's - #59

Merged
yujiezhang-ops merged 1 commit into
mainfrom
fix/select-open-list-styling
Aug 6, 2026
Merged

fix: make the select popup ours to style, not the OS's#59
yujiezhang-ops merged 1 commit into
mainfrom
fix/select-open-list-styling

Conversation

@yujiezhang-ops

Copy link
Copy Markdown
Collaborator

Closes #55.

#55 was closed by #58, but only for the closed state. #58 said so plainly: "The open <option> list stays OS-drawn. CSS cannot reach it, so this unifies the closed state only." On macOS that leaves a system popup opening out of a UI that looks nothing like one — and the open list is the part a user is actually looking at while choosing. So #55's premise is only half addressed, and this finishes it.

What replaces it

SelectField implements the ARIA combobox pattern with aria-activedescendant: focus stays on the trigger while the active option is named by id. Moving real DOM focus into the list is the other legal shape, but it means restoring focus on every close path.

role="combobox" is unchanged, so getByRole("combobox", { name }) still finds all three call sites exactly as it found the native elements. Only userEvent.selectOptions had to go — tests now click the trigger and then the option, which is what a user does anyway.

The cost of replacing a native element

All of it is here rather than left to be discovered in review:

  • Outside-dismiss listens on pointerdown, not click. A click listener fires after the trigger's own handler has already toggled, reopening what the user meant to close.
  • It bubbles from document rather than capturing. On the capture phase it ran before React's synthetic handlers, so the pointerdown that opened the list closed it again on the way through. The contains() checks are what make the bubble phase safe.
  • Arrowing sets an active index without committing. Otherwise a user browsing options with the keyboard would apply each one in passing.
  • The list flips above the trigger when there is no room below. .app-window is overflow: hidden, so a list running past the sidebar's bottom edge would be clipped, not scrolled — which is precisely the case for the two pickers docked near that edge.
  • Typeahead extends a query within a second, so de reaches 深色 instead of restarting each letter.

Two call-site changes worth flagging

The two language selects collapse into one. They differed only in option text (中文 vs 中), with CSS showing one and hiding the other per breakpoint. The short labels now live in the option list, which is ours and is not clipped to the 72px rail's width.

ProviderSegment's <label> becomes a <span>. htmlFor only associates with form elements and the trigger is a button, so SelectField carries the accessible name itself.

Verification

  • pnpm run test — 25 files, 176 tests pass (10 new for SelectField, covering pointer commit, keyboard commit, arrow-without-commit, outside dismiss, Escape, and the preserved combobox role)
  • pnpm run build and tsc --noEmit pass
  • go test ./... and go vet ./... pass
  • No stale references to .language-select-wide / .language-select-compact remain

The e2e suite is updated for the two-step interaction, and its coverage-collision check now targets the new trigger selectors.

🤖 Generated with Claude Code

#58 unified the closed state of all four selects, and said plainly that the
open <option> list stays OS-drawn because CSS cannot reach it. On macOS that
left a system popup opening out of a UI that looks nothing like one -- the one
part of the control a user actually looks at while choosing.

So the element goes. SelectField implements the ARIA combobox pattern with
aria-activedescendant: focus stays on the trigger and the active option is
named by id, which avoids restoring focus on every close path. role="combobox"
is unchanged, so getByRole("combobox", { name }) still finds all three call
sites; only userEvent.selectOptions had to go, and tests now click the trigger
and then the option, which is what a user does.

What replacing a native element costs, all of it here rather than discovered
later: outside-dismiss listens on pointerdown rather than click, because a
click listener fires after the trigger's own handler and reopens what the user
meant to close, and it bubbles from document rather than capturing, because on
the capture phase it ran before React's handlers and closed the list that had
just opened. Arrowing sets an active index without committing, so browsing with
the keyboard does not apply each option in passing. Typeahead extends a query
within a second. The list flips above the trigger when there is no room below,
since .app-window is overflow: hidden and would clip it rather than scroll --
which is exactly the case for the two sidebar pickers sitting near the bottom
edge.

The two language selects collapse into one. They differed only in option text
("中文" vs "中"), with CSS showing one per breakpoint; now the short labels live
in a list that is ours and is not clipped to the 72px rail's width.

ProviderSegment's label becomes a span: htmlFor associates with form elements,
and the trigger is a button, so SelectField carries the accessible name itself.
@yujiezhang-ops
yujiezhang-ops merged commit 133e608 into main Aug 6, 2026
4 checks passed
@yujiezhang-ops
yujiezhang-ops deleted the fix/select-open-list-styling branch August 7, 2026 09:53
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.

[P2] 全部下拉框都是 macOS 原生外观,与产品其余控件不成一套

1 participant