Skip to content

feat(ui): add SearchableSelect component with keyboard navigation#594

Merged
yannickmonney merged 4 commits into
mainfrom
feat/searchable-select
Feb 27, 2026
Merged

feat(ui): add SearchableSelect component with keyboard navigation#594
yannickmonney merged 4 commits into
mainfrom
feat/searchable-select

Conversation

@yannickmonney
Copy link
Copy Markdown
Contributor

@yannickmonney yannickmonney commented Feb 27, 2026

Summary by CodeRabbit

  • New Features

    • Added SearchableSelect component featuring searchable dropdown functionality with keyboard navigation (arrow keys, Home/End), disabled option support, optional footer content, and full accessibility compliance.
  • Documentation

    • Added Storybook stories demonstrating SearchableSelect usage scenarios including basic options, descriptions, pre-selected values, footers, large lists, and disabled options.
  • Tests

    • Added comprehensive test suite covering rendering, keyboard navigation, filtering, accessibility, and edge cases.

Extract a reusable SearchableSelect from the chat AgentSelector.
Supports search filtering, arrow key navigation, disabled options,
customizable trigger/footer slots, and full ARIA combobox semantics.

- Add SearchableSelect component, tests (32), and Storybook stories
- Refactor AgentSelector to use SearchableSelect
- Fix missing vitest client project setupFiles for jest-dom matchers
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 27, 2026

Greptile Summary

Introduced a reusable SearchableSelect component with comprehensive keyboard navigation and accessibility features, then refactored AgentSelector to use it.

Key Changes:

  • New SearchableSelect component with search filtering, keyboard navigation (Arrow keys, Home/End, Enter), and proper ARIA attributes
  • Comprehensive test suite covering rendering, interactions, keyboard navigation, and accessibility
  • Storybook stories documenting various usage patterns
  • AgentSelector refactored to use the new component, removing ~100 lines of duplicate code
  • Added UI test setup configuration for proper browser API mocking

Confidence Score: 5/5

  • This PR is safe to merge with high confidence
  • The code is well-architected with comprehensive test coverage, proper accessibility implementation, and successful refactoring that reduces code duplication. The component follows established patterns using Radix UI primitives.
  • No files require special attention

Important Files Changed

Filename Overview
services/platform/app/components/ui/forms/searchable-select.tsx Well-structured component with proper accessibility, keyboard navigation, and state management
services/platform/app/components/ui/forms/searchable-select.test.tsx Comprehensive test coverage for rendering, interactions, keyboard navigation, and accessibility
services/platform/app/features/chat/components/agent-selector.tsx Successfully refactored to use SearchableSelect, removed ~100 lines of duplicate code while maintaining functionality

Last reviewed commit: 954d0f5

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new SearchableSelect component with full test coverage and Storybook documentation, then refactors the AgentSelector component to use it. The SearchableSelect provides a searchable, keyboard-navigable dropdown with filtering, accessibility features (ARIA attributes, keyboard navigation), optional descriptions, disabled option support, and customizable styling. The AgentSelector refactor removes custom dropdown logic and replaces it with the new component, simplifying the implementation by delegating search, rendering, and interaction handling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding a new SearchableSelect UI component with keyboard navigation support, which aligns with the primary additions across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/searchable-select

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@services/platform/app/components/ui/forms/searchable-select.test.tsx`:
- Around line 18-36: The test helper renderSelect creates an onOpenChange mock
but never passes it into the SearchableSelect, so open-state callbacks can't be
asserted; update renderSelect to pass the onOpenChange prop into the rendered
<SearchableSelect> (or remove the mock if tests don't need it) so the
onOpenChange spy you return is actually wired to the component; look for the
renderSelect function and the SearchableSelect JSX and add the
onOpenChange={onOpenChange} prop.

In `@services/platform/app/components/ui/forms/searchable-select.tsx`:
- Around line 131-138: The initializeHighlight function can incorrectly default
to index 0 when the current value is not present in filteredOptions; modify
initializeHighlight to defensively handle that case by checking if idx >= 0 and
calling setHighlightedIndex(idx), otherwise avoid forcing index 0 — for example
setHighlightedIndex(-1) or leave the previous highlighted index if
filteredOptions is empty or the value is absent; update logic around
initializeHighlight (references: initializeHighlight, value, filteredOptions,
setHighlightedIndex) and add a short comment noting the component currently
clears search on close so this is a defensive guard.
- Around line 327-332: Remove the unreachable keyboard handler from the option
div in the SearchableSelect component: delete the onKeyDown={(e) => { if (e.key
=== 'Enter' || e.key === ' ') { e.preventDefault(); if (!option.disabled)
onSelect(option.value); } }} JSX prop on the option element (or its equivalent
handler) since the div has no tabIndex and keyboard focus is handled by the
search input; if keyboard support for options is desired instead, add a proper
tabIndex and focus management rather than leaving this dead handler.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 78c0db1 and 954d0f5.

📒 Files selected for processing (5)
  • services/platform/app/components/ui/forms/searchable-select.stories.tsx
  • services/platform/app/components/ui/forms/searchable-select.test.tsx
  • services/platform/app/components/ui/forms/searchable-select.tsx
  • services/platform/app/features/chat/components/agent-selector.tsx
  • services/platform/vitest.config.mjs

Comment on lines +18 to +36
function renderSelect(
overrides: Partial<React.ComponentProps<typeof SearchableSelect>> = {},
) {
const onValueChange = vi.fn();
const onOpenChange = vi.fn();
const result = render(
<SearchableSelect
value={null}
onValueChange={onValueChange}
options={options}
trigger={<button type="button">Open select</button>}
searchPlaceholder="Search..."
emptyText="No results"
aria-label="Test listbox"
{...overrides}
/>,
);
return { ...result, onValueChange, onOpenChange };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

onOpenChange mock is created but not passed to the component.

The onOpenChange spy is created and returned but never passed to SearchableSelect. If any tests need to verify open state change callbacks, they will fail silently.

🐛 Proposed fix
 function renderSelect(
   overrides: Partial<React.ComponentProps<typeof SearchableSelect>> = {},
 ) {
   const onValueChange = vi.fn();
   const onOpenChange = vi.fn();
   const result = render(
     <SearchableSelect
       value={null}
       onValueChange={onValueChange}
+      onOpenChange={onOpenChange}
       options={options}
       trigger={<button type="button">Open select</button>}
       searchPlaceholder="Search..."
       emptyText="No results"
       aria-label="Test listbox"
       {...overrides}
     />,
   );
   return { ...result, onValueChange, onOpenChange };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/forms/searchable-select.test.tsx` around
lines 18 - 36, The test helper renderSelect creates an onOpenChange mock but
never passes it into the SearchableSelect, so open-state callbacks can't be
asserted; update renderSelect to pass the onOpenChange prop into the rendered
<SearchableSelect> (or remove the mock if tests don't need it) so the
onOpenChange spy you return is actually wired to the component; look for the
renderSelect function and the SearchableSelect JSX and add the
onOpenChange={onOpenChange} prop.

Comment on lines +131 to +138
const initializeHighlight = useCallback(() => {
if (value) {
const idx = filteredOptions.findIndex((o) => o.value === value);
setHighlightedIndex(idx >= 0 ? idx : 0);
} else {
setHighlightedIndex(0);
}
}, [value, filteredOptions]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider edge case: initializeHighlight may not find value in filtered list.

When initializeHighlight runs, the filteredOptions list is unfiltered (search is empty on open), so this works correctly. However, if the component is opened with a pre-existing search value, the selected value might not be in filteredOptions, causing highlight to default to index 0 unexpectedly.

Since search is cleared on close (line 156), this is currently safe, but documenting this assumption or adding a defensive check would improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/forms/searchable-select.tsx` around lines
131 - 138, The initializeHighlight function can incorrectly default to index 0
when the current value is not present in filteredOptions; modify
initializeHighlight to defensively handle that case by checking if idx >= 0 and
calling setHighlightedIndex(idx), otherwise avoid forcing index 0 — for example
setHighlightedIndex(-1) or leave the previous highlighted index if
filteredOptions is empty or the value is absent; update logic around
initializeHighlight (references: initializeHighlight, value, filteredOptions,
setHighlightedIndex) and add a short comment noting the component currently
clears search on close so this is a defensive guard.

Comment on lines +327 to +332
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!option.disabled) onSelect(option.value);
}
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

The onKeyDown handler on the option div may be unreachable.

Since the option div doesn't have tabIndex, it cannot receive keyboard focus, so this onKeyDown handler will never be triggered. Keyboard navigation is handled by the search input, making this handler dead code.

♻️ Proposed fix: remove unreachable handler
       onClick={() => !option.disabled && onSelect(option.value)}
-      onKeyDown={(e) => {
-        if (e.key === 'Enter' || e.key === ' ') {
-          e.preventDefault();
-          if (!option.disabled) onSelect(option.value);
-        }
-      }}
       onMouseEnter={() => onMouseEnter(index)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/components/ui/forms/searchable-select.tsx` around lines
327 - 332, Remove the unreachable keyboard handler from the option div in the
SearchableSelect component: delete the onKeyDown={(e) => { if (e.key === 'Enter'
|| e.key === ' ') { e.preventDefault(); if (!option.disabled)
onSelect(option.value); } }} JSX prop on the option element (or its equivalent
handler) since the div has no tabIndex and keyboard focus is handled by the
search input; if keyboard support for options is desired instead, add a proper
tabIndex and focus management rather than leaving this dead handler.

@yannickmonney yannickmonney changed the title Feat/searchable select feat(ui): add SearchableSelect component with keyboard navigation Feb 27, 2026
- Remove unused onOpenChange mock from test helper
- Guard initializeHighlight against empty filteredOptions
- Replace dead onKeyDown on option div with lint suppression
  (keyboard is handled on the combobox input via aria-activedescendant)
@yannickmonney yannickmonney merged commit 115eba2 into main Feb 27, 2026
17 checks passed
@yannickmonney yannickmonney deleted the feat/searchable-select branch February 27, 2026 15:40
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.

1 participant