Files
vibe-kanban/frontend/src/components/ui-new/primitives/SearchableDropdown.tsx
Louis Knight-Webb 6cb45f54c1 Fix search input losing focus when hovering over dropdown items (Vibe Kanban) (#1966)
* Done! Here's a summary of the changes:

## Changes Made

### 1. `Dropdown.tsx` (lines 140-190)
- Added `preventFocusOnHover?: boolean` prop to `DropdownMenuItemProps` interface
- Added `onPointerMove` handler that calls `e.preventDefault()` when `preventFocusOnHover` is true, which stops Radix from stealing focus on hover

### 2. `SearchableDropdown.tsx` (line 110)
- Added `preventFocusOnHover` prop to `DropdownMenuItem`

### 3. `ProjectSelectorContainer.tsx` (lines 181, 208)
- Added `preventFocusOnHover` prop to both `DropdownMenuItem` components (the "Create new project" button and the virtualized project items)

The fix uses `e.preventDefault()` on `onPointerMove` to disable Radix's hover-to-focus behavior, as documented in [Radix UI Issue #2193](https://github.com/radix-ui/primitives/issues/2193). This preserves all other functionality (click, keyboard navigation, selection) while allowing the search input to retain focus when hovering over items.

* Done! I've added the `onPointerLeave` handler to `DropdownMenuItem` alongside the existing `onPointerMove` handler. Both now call `e.preventDefault()` when `preventFocusOnHover` is true.

**Summary of the fix:**
- Added `onPointerLeave` destructuring from props (line 161)
- Added `onPointerLeave` handler that calls `e.preventDefault()` when `preventFocusOnHover` is true (lines 187-194)

Per the [Radix UI Issue #2193](https://github.com/radix-ui/primitives/issues/2193), **both** `onPointerMove` and `onPointerLeave` need `preventDefault()` to fully disable hover-to-focus behavior.
2026-01-12 15:16:33 +00:00

128 lines
3.6 KiB
TypeScript

import type { RefObject } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSearchInput,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './Dropdown';
interface SearchableDropdownProps<T> {
/** Array of filtered items to display */
filteredItems: T[];
/** Currently selected value (matched against getItemKey) */
selectedValue?: string | null;
/** Extract unique key from item */
getItemKey: (item: T) => string;
/** Extract display label from item */
getItemLabel: (item: T) => string;
/** Called when an item is selected */
onSelect: (item: T) => void;
/** Trigger element (uses asChild pattern) */
trigger: React.ReactNode;
/** Search state */
searchTerm: string;
onSearchTermChange: (value: string) => void;
/** Highlight state */
highlightedIndex: number | null;
onHighlightedIndexChange: (index: number | null) => void;
/** Open state */
open: boolean;
onOpenChange: (open: boolean) => void;
/** Keyboard handler */
onKeyDown: (e: React.KeyboardEvent) => void;
/** Virtuoso ref for scrolling */
virtuosoRef: RefObject<VirtuosoHandle | null>;
/** Class name for dropdown content */
contentClassName?: string;
/** Placeholder text for search input */
placeholder?: string;
/** Message shown when no items match */
emptyMessage?: string;
/** Optional badge text for each item */
getItemBadge?: (item: T) => string | undefined;
}
export function SearchableDropdown<T>({
filteredItems,
selectedValue,
getItemKey,
getItemLabel,
onSelect,
trigger,
searchTerm,
onSearchTermChange,
highlightedIndex,
onHighlightedIndexChange,
open,
onOpenChange,
onKeyDown,
virtuosoRef,
contentClassName,
placeholder = 'Search',
emptyMessage = 'No items found',
getItemBadge,
}: SearchableDropdownProps<T>) {
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}>
<DropdownMenuSearchInput
placeholder={placeholder}
value={searchTerm}
onValueChange={onSearchTermChange}
onKeyDown={onKeyDown}
/>
<DropdownMenuSeparator />
{filteredItems.length === 0 ? (
<div className="px-base py-half text-sm text-low text-center">
{emptyMessage}
</div>
) : (
<Virtuoso
ref={virtuosoRef as React.RefObject<VirtuosoHandle>}
style={{ height: '16rem' }}
totalCount={filteredItems.length}
computeItemKey={(idx) =>
getItemKey(filteredItems[idx]) ?? String(idx)
}
itemContent={(idx) => {
const item = filteredItems[idx];
const key = getItemKey(item);
const isHighlighted = idx === highlightedIndex;
const isSelected = selectedValue === key;
return (
<DropdownMenuItem
onSelect={() => onSelect(item)}
onMouseEnter={() => onHighlightedIndexChange(idx)}
preventFocusOnHover
badge={getItemBadge?.(item)}
className={cn(
isSelected && 'bg-secondary',
isHighlighted && 'bg-secondary'
)}
>
{getItemLabel(item)}
</DropdownMenuItem>
);
}}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}