Skip to content

Extract shared listbox keyboard navigation into a hook #376

Description

@martincarapia

Problem

Five components independently implement the same roving-highlight arrow-key navigation over a list. The logic is duplicated, and the behavior has already drifted between them.

Component Wrap arithmetic Home / End Notes
Autocomplete yes no uses a -1 sentinel for "nothing active"
Messaging/MessageComposer yes no mention suggestions
CodeLookup yes yes switch-based
Select yes yes switch-based
AI/ComposerModelSelector yes yes plus back-navigation out of a drill-down view

Each one hand-rolls the same modular arithmetic:

setHighlight((current) => (current + 1) % length);
setHighlight((current) => (current - 1 + length) % length);

The inconsistency is the real cost: only three of the five support Home/End, so keyboard users get different capabilities depending on which list they happen to be in.

Proposal

Design a useListNavigation hook in src/hooks/ and migrate all five components to it.

This is not a mechanical extraction — the hook needs deliberate API decisions before it is worth doing:

  • Wrap vs. clamp at the list boundaries.
  • Sentinel support. Autocomplete treats -1 as a valid "no active row" state, which changes what ArrowUp from index 0 should do. The others have no such state.
  • Opt-in keys. Home/End should be available but not forced on every consumer.
  • Optional back-navigation, for drill-down menus like ComposerModelSelector.

Getting these wrong produces an abstraction shaped by whichever component was migrated first, so the design should be settled up front rather than discovered during migration.

Out of scope

Slider, Tabs, and MediaEditor also handle ArrowDown/ArrowUp, but for value adjustment and tab movement rather than list highlighting. They should not be folded into this hook.

Context

Surfaced during review of the reasoning-effort work on ComposerModelSelector, which added a second listbox to that component. A local helper to deduplicate the two handlers within that one file was written and then deliberately reverted — since this refactor supersedes it, carrying it would have meant reviewing code destined to be replaced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions