* 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.
128 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|