Skip to content

Commit 9b4920d

Browse files
committed
fix keyboard nav and keyboard selection in tool-inp
1 parent ad76f61 commit 9b4920d

File tree

3 files changed

+157
-112
lines changed
  • apps/sim
    • app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input
    • components/emcn/components/popover

3 files changed

+157
-112
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx

Lines changed: 115 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type React from 'react'
2-
import {
1+
import React, {
32
createContext,
43
type ReactNode,
54
useCallback,
@@ -60,116 +59,126 @@ interface CommandSeparatorProps {
6059
className?: string
6160
}
6261

63-
export function Command({
64-
children,
65-
className,
66-
filter,
67-
searchQuery: externalSearchQuery,
68-
}: CommandProps) {
69-
const [internalSearchQuery, setInternalSearchQuery] = useState('')
70-
const [activeIndex, setActiveIndex] = useState(-1)
71-
const [items, setItems] = useState<string[]>([])
72-
const [filteredItems, setFilteredItems] = useState<string[]>([])
73-
74-
// Use external searchQuery if provided, otherwise use internal state
75-
const searchQuery = externalSearchQuery ?? internalSearchQuery
76-
77-
const registerItem = useCallback((id: string) => {
78-
setItems((prev) => {
79-
if (prev.includes(id)) return prev
80-
return [...prev, id]
81-
})
82-
}, [])
83-
84-
const unregisterItem = useCallback((id: string) => {
85-
setItems((prev) => prev.filter((item) => item !== id))
86-
}, [])
87-
88-
const selectItem = useCallback(
89-
(id: string) => {
90-
const index = filteredItems.indexOf(id)
91-
if (index >= 0) {
92-
setActiveIndex(index)
93-
}
94-
},
95-
[filteredItems]
96-
)
62+
export const Command = React.forwardRef<HTMLDivElement, CommandProps>(
63+
({ children, className, filter, searchQuery: externalSearchQuery }, ref) => {
64+
const [internalSearchQuery, setInternalSearchQuery] = useState('')
65+
const [activeIndex, setActiveIndex] = useState(-1)
66+
const [items, setItems] = useState<string[]>([])
67+
const [filteredItems, setFilteredItems] = useState<string[]>([])
9768

98-
useEffect(() => {
99-
if (!searchQuery) {
100-
setFilteredItems(items)
101-
return
102-
}
69+
const searchQuery = externalSearchQuery ?? internalSearchQuery
10370

104-
const filtered = items
105-
.map((item) => {
106-
const score = filter ? filter(item, searchQuery) : defaultFilter(item, searchQuery)
107-
return { item, score }
71+
const registerItem = useCallback((id: string) => {
72+
setItems((prev) => {
73+
if (prev.includes(id)) return prev
74+
return [...prev, id]
10875
})
109-
.filter((item) => item.score > 0)
110-
.sort((a, b) => b.score - a.score)
111-
.map((item) => item.item)
112-
113-
setFilteredItems(filtered)
114-
setActiveIndex(filtered.length > 0 ? 0 : -1)
115-
}, [searchQuery, items, filter])
116-
117-
const defaultFilter = useCallback((value: string, search: string): number => {
118-
const normalizedValue = value.toLowerCase()
119-
const normalizedSearch = search.toLowerCase()
120-
121-
if (normalizedValue === normalizedSearch) return 1
122-
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
123-
if (normalizedValue.includes(normalizedSearch)) return 0.6
124-
return 0
125-
}, [])
126-
127-
const handleKeyDown = useCallback(
128-
(e: React.KeyboardEvent) => {
129-
if (filteredItems.length === 0) return
130-
131-
switch (e.key) {
132-
case 'ArrowDown':
133-
e.preventDefault()
134-
setActiveIndex((prev) => (prev + 1) % filteredItems.length)
135-
break
136-
case 'ArrowUp':
137-
e.preventDefault()
138-
setActiveIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
139-
break
140-
case 'Enter':
141-
if (activeIndex >= 0) {
142-
e.preventDefault()
143-
document.getElementById(filteredItems[activeIndex])?.click()
144-
}
145-
break
76+
}, [])
77+
78+
const unregisterItem = useCallback((id: string) => {
79+
setItems((prev) => prev.filter((item) => item !== id))
80+
}, [])
81+
82+
const selectItem = useCallback(
83+
(id: string) => {
84+
const index = filteredItems.indexOf(id)
85+
if (index >= 0) {
86+
setActiveIndex(index)
87+
}
88+
},
89+
[filteredItems]
90+
)
91+
92+
useEffect(() => {
93+
if (!searchQuery) {
94+
setFilteredItems(items)
95+
return
14696
}
147-
},
148-
[filteredItems, activeIndex]
149-
)
15097

151-
const contextValue = useMemo(
152-
() => ({
153-
searchQuery,
154-
setSearchQuery: setInternalSearchQuery,
155-
activeIndex,
156-
setActiveIndex,
157-
filteredItems,
158-
registerItem,
159-
unregisterItem,
160-
selectItem,
161-
}),
162-
[searchQuery, activeIndex, filteredItems, registerItem, unregisterItem, selectItem]
163-
)
98+
const filtered = items
99+
.map((item) => {
100+
const score = filter ? filter(item, searchQuery) : defaultFilter(item, searchQuery)
101+
return { item, score }
102+
})
103+
.filter((item) => item.score > 0)
104+
.sort((a, b) => b.score - a.score)
105+
.map((item) => item.item)
106+
107+
setFilteredItems(filtered)
108+
setActiveIndex(filtered.length > 0 ? 0 : -1)
109+
}, [searchQuery, items, filter])
110+
111+
useEffect(() => {
112+
if (activeIndex >= 0 && filteredItems[activeIndex]) {
113+
const activeElement = document.getElementById(filteredItems[activeIndex])
114+
if (activeElement) {
115+
activeElement.scrollIntoView({
116+
behavior: 'smooth',
117+
block: 'nearest',
118+
})
119+
}
120+
}
121+
}, [activeIndex, filteredItems])
164122

165-
return (
166-
<CommandContext.Provider value={contextValue}>
167-
<div className={cn('flex w-full flex-col', className)} onKeyDown={handleKeyDown}>
168-
{children}
169-
</div>
170-
</CommandContext.Provider>
171-
)
172-
}
123+
const defaultFilter = useCallback((value: string, search: string): number => {
124+
const normalizedValue = value.toLowerCase()
125+
const normalizedSearch = search.toLowerCase()
126+
127+
if (normalizedValue === normalizedSearch) return 1
128+
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
129+
if (normalizedValue.includes(normalizedSearch)) return 0.6
130+
return 0
131+
}, [])
132+
133+
const handleKeyDown = useCallback(
134+
(e: React.KeyboardEvent) => {
135+
if (filteredItems.length === 0) return
136+
137+
switch (e.key) {
138+
case 'ArrowDown':
139+
e.preventDefault()
140+
setActiveIndex((prev) => (prev + 1) % filteredItems.length)
141+
break
142+
case 'ArrowUp':
143+
e.preventDefault()
144+
setActiveIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
145+
break
146+
case 'Enter':
147+
if (activeIndex >= 0) {
148+
e.preventDefault()
149+
document.getElementById(filteredItems[activeIndex])?.click()
150+
}
151+
break
152+
}
153+
},
154+
[filteredItems, activeIndex]
155+
)
156+
157+
const contextValue = useMemo(
158+
() => ({
159+
searchQuery,
160+
setSearchQuery: setInternalSearchQuery,
161+
activeIndex,
162+
setActiveIndex,
163+
filteredItems,
164+
registerItem,
165+
unregisterItem,
166+
selectItem,
167+
}),
168+
[searchQuery, activeIndex, filteredItems, registerItem, unregisterItem, selectItem]
169+
)
170+
171+
return (
172+
<CommandContext.Provider value={contextValue}>
173+
<div ref={ref} className={cn('flex w-full flex-col', className)} onKeyDown={handleKeyDown}>
174+
{children}
175+
</div>
176+
</CommandContext.Provider>
177+
)
178+
}
179+
)
180+
181+
Command.displayName = 'Command'
173182

174183
export function CommandList({ children, className }: CommandListProps) {
175184
return <div className={cn(className)}>{children}</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react'
2-
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { useQuery } from '@tanstack/react-query'
44
import { Loader2, PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react'
55
import { useParams } from 'next/navigation'
@@ -673,6 +673,29 @@ export function ToolInput({
673673
refreshTools,
674674
} = useMcpTools(workspaceId)
675675

676+
const commandRef = useRef<HTMLDivElement>(null)
677+
678+
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
679+
// Allow arrow keys and Enter to be handled by ToolCommand
680+
if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
681+
// Dispatch the keyboard event to the ToolCommand component
682+
commandRef.current?.dispatchEvent(
683+
new KeyboardEvent('keydown', {
684+
key: e.key,
685+
bubbles: true,
686+
cancelable: true,
687+
})
688+
)
689+
}
690+
}, [])
691+
692+
// Reset search query when popover opens
693+
useEffect(() => {
694+
if (open) {
695+
setSearchQuery('')
696+
}
697+
}, [open])
698+
676699
const modelValue = useSubBlockStore.getState().getValue(blockId, 'model')
677700
const model = typeof modelValue === 'string' ? modelValue : ''
678701
const provider = model ? getProviderFromModel(model) : ''
@@ -1532,9 +1555,13 @@ export function ToolInput({
15321555
align='start'
15331556
sideOffset={6}
15341557
>
1535-
<PopoverSearch placeholder='Search tools...' onValueChange={setSearchQuery} />
1558+
<PopoverSearch
1559+
placeholder='Search tools...'
1560+
onValueChange={setSearchQuery}
1561+
onKeyDown={handleSearchKeyDown}
1562+
/>
15361563
<PopoverScrollArea>
1537-
<ToolCommand.Root filter={customFilter} searchQuery={searchQuery}>
1564+
<ToolCommand.Root ref={commandRef} filter={customFilter} searchQuery={searchQuery}>
15381565
<ToolCommand.List>
15391566
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
15401567

@@ -2072,9 +2099,13 @@ export function ToolInput({
20722099
align='start'
20732100
sideOffset={6}
20742101
>
2075-
<PopoverSearch placeholder='Search tools...' onValueChange={setSearchQuery} />
2102+
<PopoverSearch
2103+
placeholder='Search tools...'
2104+
onValueChange={setSearchQuery}
2105+
onKeyDown={handleSearchKeyDown}
2106+
/>
20762107
<PopoverScrollArea>
2077-
<ToolCommand.Root filter={customFilter} searchQuery={searchQuery}>
2108+
<ToolCommand.Root ref={commandRef} filter={customFilter} searchQuery={searchQuery}>
20782109
<ToolCommand.List>
20792110
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
20802111

apps/sim/components/emcn/components/popover/popover.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,10 @@ export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement>
670670
* Callback when search query changes
671671
*/
672672
onValueChange?: (value: string) => void
673+
/**
674+
* Callback when keyboard events occur on the search input
675+
*/
676+
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
673677
}
674678

675679
/**
@@ -688,7 +692,7 @@ export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement>
688692
* ```
689693
*/
690694
const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
691-
({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => {
695+
({ className, placeholder = 'Search...', onValueChange, onKeyDown, ...props }, ref) => {
692696
const { searchQuery, setSearchQuery } = usePopoverContext()
693697
const inputRef = React.useRef<HTMLInputElement>(null)
694698

@@ -711,6 +715,7 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
711715
placeholder={placeholder}
712716
value={searchQuery}
713717
onChange={handleChange}
718+
onKeyDown={onKeyDown}
714719
/>
715720
</div>
716721
)

0 commit comments

Comments
 (0)