Skip to content

feat(web): add typeahead keyboard selection to Select (Alpha) - #870

Merged
adrienzheng-cb merged 3 commits into
masterfrom
adrienzheng/cds-2505-select-alpha-lacks-typeahead-keyboard-selection
Sep 16, 2026
Merged

adrienzheng-cb merged 3 commits into
masterfrom
adrienzheng/cds-2505-select-alpha-lacks-typeahead-keyboard-selection

Conversation

@adrienzheng-cb

Copy link
Copy Markdown
Contributor

What changed? Why?

Adds native <select>-style typeahead keyboard selection to the web Select (Alpha):

  • A short-lived, multi-character search buffer that resets ~500ms after the last keystroke.
  • Repeated-key cycling: pressing the same letter repeatedly cycles through every option that starts with it, matching native <select> semantics.
  • Works both when the listbox is closed (opens it, then focuses the match) and while it is open, via a window-level keydown listener so keystrokes are handled consistently even after focus moves into the portaled dropdown.
  • Buffer construction and match resolution are extracted into a pure typeahead.ts module (isPrintableTypeaheadKey, normalizeOptionText, getTypeaheadMatchIndex) with focused unit tests.

The previous implementation only matched the first letter of an option and only at open time, so users could not type multiple characters or cycle between same-prefixed options.

Testing

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Focus a web Select (Alpha) and type characters: with the listbox closed, typing opens it and focuses the first match; with it open, continue typing to refine the prefix match, or repeat a single letter to cycle through same-prefixed options. See typeahead.test.ts for the pure matching logic and the added Select.test.tsx cases for integration behavior.

Linear: https://linear.app/coinbase/issue/CDS-2505

Change management

type=routine
risk=low
impact=sev5

automerge=false

Made with Cursor

@linear

linear Bot commented Sep 1, 2026

Copy link
Copy Markdown

CDS-2505

@cb-heimdall

cb-heimdall commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

✅ Heimdall Review Status

Requirement Status More Info
Reviews 1/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1
CODEOWNERS ✅ See below

CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 1/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@adrienzheng-cb
adrienzheng-cb force-pushed the adrienzheng/cds-2505-select-alpha-lacks-typeahead-keyboard-selection branch 3 times, most recently from 64c2e69 to 2fa6852 Compare September 3, 2026 17:46

@cb-ekuersch cb-ekuersch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot going on here - i think it may help to review together. We should potentailly consider the possibility of abstracting this entire typeahead behavior into a single hook that can be called in the component render instead of inlining all this complex react logc in an already compex/long component render body

type TypeaheadKeyEvent = Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey'>;

// Bare printable key, no modifier (shared by closed and open paths).
export function isTypeaheadKeyEvent(event: TypeaheadKeyEvent): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why not make this KeyboardEvent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched this to the explicit React.KeyboardEvent | KeyboardEvent union for readability — it accepts both React's synthetic event from the control onKeyDown and the native event from the window keydown listener, without the structural Pick.


useEffect(
() => () => {
if (typeaheadResetTimeoutRef.current) clearTimeout(typeaheadResetTimeoutRef.current);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should clear this in the cleanup of the effect that creates the timeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see that the timeout is crated in a user interaction so i dont think this effect just to do cleanup in even necessary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — I removed the standalone cleanup-only effect and folded the clearTimeout into the return of the window-listener effect (the effect that actually drives the open typeahead interaction). A reset timeout is only ever pending as part of an open interaction, so that effect's cleanup clears it both on the open→closed transition and on unmount. See the reply on your follow-up comment for the reasoning about why the timer is safe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the dedicated cleanup-only effect is gone. Two things worth noting: (1) the reset timeout's callback only assigns "" to a ref (typeaheadBufferRef), so it never calls setState or focus; a late fire on an unmounted tree is a harmless no-op, not a stray-timer bug. (2) Even so, I kept an explicit clear by folding it into the window-listener effect's cleanup, which runs on the open→closed transition and on unmount — so no timer lingers. Net: no separate effect just for cleanup, and no stray timer.

);

useEffect(() => {
if (!open || !pendingTypeAheadKeyRef.current) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we label what these different effects are for? Maybe there is value in giving them names and pulling them into custom hooks just for oganization's sake (e.g. useFocusInPortaledDropdown etc.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is largely addressed: all the typeahead effects were extracted out of Select.tsx into a dedicated useTypeahead hook (useTypeahead.ts), which is the "pull them into a custom hook for organization" direction you suggested. Inside the hook I also added terse labels — the type-to-open focus effect and the window-listener effect each have a one-line comment describing their purpose. I intentionally kept it as a single cohesive hook rather than splitting into several micro-hooks (e.g. useFocusInPortaledDropdown), since the effects share the buffer/timeout/focus refs and splitting would add indirection without real clarity. Happy to split further if you feel strongly.

The web Select (Alpha) previously only matched the first letter of an
option and only when opening the listbox. Add native <select>-style
typeahead: a multi-character search buffer that resets ~500ms after the
last keystroke, repeated-key cycling through options that share a first
letter, and matching that works both when the listbox is open and
closed. Buffer/matching logic is extracted into a pure typeahead module
with focused unit tests.

Refs CDS-2505

Co-authored-by: Cursor <cursoragent@cursor.com>
@adrienzheng-cb
adrienzheng-cb force-pushed the adrienzheng/cds-2505-select-alpha-lacks-typeahead-keyboard-selection branch from 88513d5 to d56c27f Compare September 16, 2026 15:49
cb-ekuersch
cb-ekuersch previously approved these changes Sep 16, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
@adrienzheng-cb
adrienzheng-cb merged commit e3bbe9d into master Sep 16, 2026
31 checks passed
@adrienzheng-cb
adrienzheng-cb deleted the adrienzheng/cds-2505-select-alpha-lacks-typeahead-keyboard-selection branch September 16, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants