-
Notifications
You must be signed in to change notification settings - Fork 0
Enable typing to open and search select dropdowns #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,7 +57,7 @@ export interface SelectProps<T extends React.Key = string> | |
|
|
||
| // Default search input built on top of CommandInput. Supports cmdk props at runtime. | ||
| const DefaultSearchInput = forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<typeof CommandInput>>( | ||
| (props, _ref) => <CommandInput {...props} />, | ||
| (props, ref) => <CommandInput {...props} />, | ||
| ); | ||
| DefaultSearchInput.displayName = 'SelectSearchInput'; | ||
|
|
||
|
|
@@ -84,17 +84,34 @@ export function Select<T extends React.Key = string>({ | |
| const triggerRef = useRef<HTMLButtonElement>(null); | ||
| const popoverRef = useRef<HTMLDivElement>(null); | ||
| const selectedItemRef = useRef<HTMLElement>(null); | ||
| const searchInputRef = useRef<HTMLInputElement>(null); | ||
| const [searchQuery, setSearchQuery] = useState(''); | ||
| // No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable | ||
|
|
||
| // When opening, ensure the currently selected option is the active item for keyboard nav | ||
| // and focus the search input if searchable | ||
| useEffect(() => { | ||
| if (!popoverState.isOpen) return; | ||
| if (!popoverState.isOpen) { | ||
| // Clear search query when closing | ||
| setSearchQuery(''); | ||
| return; | ||
| } | ||
| requestAnimationFrame(() => { | ||
| if (searchable) { | ||
| // Query for the input element since CommandInput uses asChild and ref forwarding is complex | ||
| const inputElement = popoverRef.current?.querySelector<HTMLInputElement>('input[type="text"]'); | ||
| if (inputElement) { | ||
| inputElement.focus(); | ||
| const selectionEnd = inputElement.value.length; | ||
| if (selectionEnd > 0) { | ||
| inputElement.setSelectionRange(selectionEnd, selectionEnd); | ||
| } | ||
| } | ||
| } | ||
| const selectedEl = selectedItemRef.current as HTMLElement | null; | ||
| if (selectedEl) selectedEl.scrollIntoView({ block: 'center' }); | ||
| }); | ||
| }, [popoverState.isOpen]); | ||
| }, [popoverState.isOpen, searchable]); | ||
|
|
||
| const selectedOption = options.find((o) => o.value === value); | ||
|
|
||
|
|
@@ -116,6 +133,34 @@ export function Select<T extends React.Key = string>({ | |
| const ChevronIcon = components?.ChevronIcon || DefaultChevronIcon; | ||
| const SearchInput = components?.SearchInput || DefaultSearchInput; | ||
|
|
||
| // Handle keydown on trigger to open popover and start typing | ||
| const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => { | ||
| // Allow normal keyboard navigation (Enter, Space, Arrow keys, etc.) | ||
| if ( | ||
| e.key === 'Enter' || | ||
| e.key === ' ' || | ||
| e.key === 'ArrowDown' || | ||
| e.key === 'ArrowUp' || | ||
| e.key === 'Escape' || | ||
| e.key === 'Tab' | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| // If it's a printable character and searchable is enabled, open the popover and start typing | ||
| if (searchable && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { | ||
| e.preventDefault(); | ||
| if (!popoverState.isOpen) { | ||
| popoverState.open(); | ||
| } | ||
| // Set the initial search query | ||
| setSearchQuery(e.key); | ||
| } | ||
|
|
||
| // Call the original onKeyDown if provided | ||
| buttonProps.onKeyDown?.(e); | ||
| }; | ||
|
|
||
| return ( | ||
| <Popover open={popoverState.isOpen} onOpenChange={popoverState.setOpen}> | ||
| <PopoverTrigger asChild> | ||
|
|
@@ -131,6 +176,7 @@ export function Select<T extends React.Key = string>({ | |
| aria-haspopup="listbox" | ||
| aria-expanded={popoverState.isOpen} | ||
| aria-controls={listboxId} | ||
| onKeyDown={handleTriggerKeyDown} | ||
| {...buttonProps} | ||
| > | ||
| {value != null && value !== '' ? (selectedOption?.label ?? String(value)) : placeholder} | ||
|
|
@@ -159,6 +205,7 @@ export function Select<T extends React.Key = string>({ | |
| {searchable && ( | ||
| <div className="px-1.5 pb-1.5 pt-1.5"> | ||
| <SearchInput | ||
| ref={searchInputRef} | ||
| placeholder="Search..." | ||
|
Comment on lines
207
to
209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n packages/components/src/ui/select.tsx | head -250 | tail -100Repository: lambda-curry/forms Length of output: 5451 🏁 Script executed: wc -l packages/components/src/ui/select.tsxRepository: lambda-curry/forms Length of output: 103 🏁 Script executed: grep -n "DefaultSearchInput\|searchInputRef" packages/components/src/ui/select.tsx | head -30Repository: lambda-curry/forms Length of output: 505 🏁 Script executed: sed -n '59,65p' packages/components/src/ui/select.tsxRepository: lambda-curry/forms Length of output: 343 🏁 Script executed: sed -n '85,105p' packages/components/src/ui/select.tsxRepository: lambda-curry/forms Length of output: 942 Forward the ref through
🛠️ Proposed fix-const DefaultSearchInput = forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<typeof CommandInput>>(
- (props, _ref) => <CommandInput {...props} />,
-);
+const DefaultSearchInput = forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<typeof CommandInput>>(
+ (props, ref) => <CommandInput ref={ref} {...props} />,
+);🤖 Prompt for AI Agents |
||
| value={searchQuery} | ||
| onValueChange={(v: string) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: lambda-curry/forms
Length of output: 1683
🏁 Script executed:
cat -n packages/components/src/ui/select.tsx | head -130Repository: lambda-curry/forms
Length of output: 5790
🏁 Script executed:
Repository: lambda-curry/forms
Length of output: 2569
🏁 Script executed:
Repository: lambda-curry/forms
Length of output: 44
🏁 Script executed:
rg "handleTriggerKeyDown" packages/components/src/ui/select.tsx -B 5 -A 15Repository: lambda-curry/forms
Length of output: 1811
Preserve consumer
onKeyDownfor navigation keys.The early return at line 139 prevents
buttonProps.onKeyDownfrom firing for Enter/Space/Arrow/Escape/Tab, breaking the prop delegation contract. Consumers passing anonKeyDownhandler expect it to be called for all keyboard events.🛠️ Proposed fix
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => { // Allow normal keyboard navigation (Enter, Space, Arrow keys, etc.) if ( e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' || e.key === 'Tab' ) { + buttonProps.onKeyDown?.(e); return; } // If it's a printable character and searchable is enabled, open the popover and start typing if (searchable && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); if (!popoverState.isOpen) { popoverState.open(); } // Set the initial search query setSearchQuery(e.key); } // Call the original onKeyDown if provided buttonProps.onKeyDown?.(e); };📝 Committable suggestion
🤖 Prompt for AI Agents