11'use client'
22
3- import React , { useCallback , useMemo , useRef , useState } from 'react'
3+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react'
44import { Paperclip } from 'lucide-react'
55import {
66 DropdownMenu ,
@@ -13,6 +13,7 @@ import {
1313 DropdownMenuTrigger ,
1414} from '@/components/emcn'
1515import { Plus , Sim } from '@/components/emcn/icons'
16+ import { cn } from '@/lib/core/utils/cn'
1617import {
1718 buildWorkflowFolderTree ,
1819 type useAvailableResources ,
@@ -41,6 +42,7 @@ export const PlusMenuDropdown = React.memo(
4142 const [ open , setOpen ] = useState ( false )
4243 const [ search , setSearch ] = useState ( '' )
4344 const [ anchorPos , setAnchorPos ] = useState < { left : number ; top : number } | null > ( null )
45+ const [ activeIndex , setActiveIndex ] = useState ( 0 )
4446 const buttonRef = useRef < HTMLButtonElement > ( null )
4547 const searchRef = useRef < HTMLInputElement > ( null )
4648 const contentRef = useRef < HTMLDivElement > ( null )
@@ -55,6 +57,7 @@ export const PlusMenuDropdown = React.memo(
5557 }
5658 setOpen ( true )
5759 setSearch ( '' )
60+ setActiveIndex ( 0 )
5861 } , [ ] )
5962
6063 React . useImperativeHandle ( ref , ( ) => ( { open : doOpen } ) , [ doOpen ] )
@@ -77,25 +80,48 @@ export const PlusMenuDropdown = React.memo(
7780 onResourceSelect ( resource )
7881 setOpen ( false )
7982 setSearch ( '' )
83+ setActiveIndex ( 0 )
8084 }
8185
86+ // Sync DOM scroll to the keyboard-highlighted filtered row.
87+ useEffect ( ( ) => {
88+ if ( ! filteredItems || filteredItems . length === 0 ) return
89+ const row = contentRef . current ?. querySelector < HTMLElement > (
90+ `[data-filtered-idx="${ activeIndex } "]`
91+ )
92+ row ?. scrollIntoView ( { block : 'nearest' } )
93+ } , [ activeIndex , filteredItems ] )
94+
95+ const getVisibleMenuItems = ( ) : HTMLElement [ ] =>
96+ Array . from (
97+ contentRef . current ?. querySelectorAll < HTMLElement > ( '[role="menuitem"]' ) ?? [ ]
98+ ) . filter ( ( el ) => el . offsetParent !== null )
99+
82100 const handleSearchKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
101+ if ( ! filteredItems ) {
102+ if ( e . key === 'ArrowDown' ) {
103+ e . preventDefault ( )
104+ getVisibleMenuItems ( ) [ 0 ] ?. focus ( )
105+ }
106+ return
107+ }
108+ if ( filteredItems . length === 0 ) return
83109 if ( e . key === 'ArrowDown' ) {
84110 e . preventDefault ( )
85- const firstItem = contentRef . current ?. querySelector < HTMLElement > ( '[role="menuitem"]' )
86- firstItem ?. focus ( )
87- } else if ( e . key === 'Enter' || e . key === 'Tab' ) {
111+ setActiveIndex ( ( i ) => Math . min ( i + 1 , filteredItems . length - 1 ) )
112+ } else if ( e . key === 'ArrowUp' ) {
88113 e . preventDefault ( )
89- const first = filteredItems ?. [ 0 ]
90- if ( first ) handleSelect ( { type : first . type , id : first . item . id , title : first . item . name } )
114+ setActiveIndex ( ( i ) => Math . max ( i - 1 , 0 ) )
115+ } else if ( e . key === 'Enter' || ( e . key === 'Tab' && ! e . shiftKey ) ) {
116+ e . preventDefault ( )
117+ const target = filteredItems [ activeIndex ] ?? filteredItems [ 0 ]
118+ if ( target ) handleSelect ( { type : target . type , id : target . item . id , title : target . item . name } )
91119 }
92120 }
93121
94122 const handleContentKeyDown = ( e : React . KeyboardEvent < HTMLDivElement > ) => {
95123 if ( e . key === 'ArrowUp' ) {
96- const items = Array . from (
97- contentRef . current ?. querySelectorAll < HTMLElement > ( '[role="menuitem"]' ) ?? [ ]
98- )
124+ const items = getVisibleMenuItems ( )
99125 if ( items [ 0 ] && items [ 0 ] === document . activeElement ) {
100126 e . preventDefault ( )
101127 searchRef . current ?. focus ( )
@@ -114,6 +140,7 @@ export const PlusMenuDropdown = React.memo(
114140 if ( ! isOpen ) {
115141 setSearch ( '' )
116142 setAnchorPos ( null )
143+ setActiveIndex ( 0 )
117144 onClose ( )
118145 }
119146 }
@@ -157,100 +184,110 @@ export const PlusMenuDropdown = React.memo(
157184 ref = { searchRef }
158185 placeholder = 'Search resources...'
159186 value = { search }
160- onChange = { ( e ) => setSearch ( e . target . value ) }
187+ onChange = { ( e ) => {
188+ setSearch ( e . target . value )
189+ setActiveIndex ( 0 )
190+ } }
161191 onKeyDown = { handleSearchKeyDown }
162192 />
163193 < div className = 'min-h-0 flex-1 overflow-y-auto' >
164- { filteredItems ? (
165- filteredItems . length > 0 ? (
166- filteredItems . map ( ( { type, item } ) => {
194+ { /* Always-mounted; swapping this subtree with filtered results makes Radix's
195+ menu FocusScope steal focus from the search input back to the content root. */ }
196+ < div hidden = { filteredItems !== null } >
197+ < DropdownMenuItem
198+ onClick = { ( ) => {
199+ setOpen ( false )
200+ onFileSelect ( )
201+ } }
202+ >
203+ < Paperclip className = 'h-[14px] w-[14px]' strokeWidth = { 2 } />
204+ < span > Attachments</ span >
205+ </ DropdownMenuItem >
206+ < DropdownMenuSub >
207+ < DropdownMenuSubTrigger >
208+ < Sim className = 'h-[14px] w-[14px]' fill = 'currentColor' />
209+ < span > Workspace</ span >
210+ </ DropdownMenuSubTrigger >
211+ < DropdownMenuSubContent >
212+ { workflowTree . length > 0 && (
213+ < DropdownMenuSub >
214+ < DropdownMenuSubTrigger >
215+ < div
216+ className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
217+ style = { {
218+ backgroundColor : '#808080' ,
219+ borderColor : '#80808060' ,
220+ backgroundClip : 'padding-box' ,
221+ } }
222+ />
223+ < span > Workflows</ span >
224+ </ DropdownMenuSubTrigger >
225+ < DropdownMenuSubContent >
226+ < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
227+ </ DropdownMenuSubContent >
228+ </ DropdownMenuSub >
229+ ) }
230+ { availableResources
231+ . filter ( ( { type } ) => type !== 'workflow' && type !== 'folder' )
232+ . map ( ( { type, items } ) => {
233+ if ( items . length === 0 ) return null
234+ const config = getResourceConfig ( type )
235+ const Icon = config . icon
236+ return (
237+ < DropdownMenuSub key = { type } >
238+ < DropdownMenuSubTrigger >
239+ < Icon className = 'h-[14px] w-[14px]' />
240+ < span > { config . label } </ span >
241+ </ DropdownMenuSubTrigger >
242+ < DropdownMenuSubContent >
243+ { items . map ( ( item ) => (
244+ < DropdownMenuItem
245+ key = { item . id }
246+ onClick = { ( ) => {
247+ handleSelect ( { type, id : item . id , title : item . name } )
248+ } }
249+ >
250+ { config . renderDropdownItem ( { item } ) }
251+ </ DropdownMenuItem >
252+ ) ) }
253+ </ DropdownMenuSubContent >
254+ </ DropdownMenuSub >
255+ )
256+ } ) }
257+ </ DropdownMenuSubContent >
258+ </ DropdownMenuSub >
259+ </ div >
260+ { /* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
261+ menu Collection, or FocusScope restores focus to the content root. */ }
262+ { filteredItems !== null &&
263+ ( filteredItems . length > 0 ? (
264+ filteredItems . map ( ( { type, item } , index ) => {
167265 const config = getResourceConfig ( type )
266+ const isActive = index === activeIndex
168267 return (
169- < DropdownMenuItem
268+ < button
170269 key = { `${ type } :${ item . id } ` }
270+ type = 'button'
271+ role = 'menuitem'
272+ data-filtered-idx = { index }
273+ onMouseEnter = { ( ) => setActiveIndex ( index ) }
171274 onClick = { ( ) => {
172- handleSelect ( {
173- type,
174- id : item . id ,
175- title : item . name ,
176- } )
275+ handleSelect ( { type, id : item . id , title : item . name } )
177276 } }
277+ className = { cn (
278+ 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' ,
279+ isActive && 'bg-[var(--surface-active)]'
280+ ) }
178281 >
179282 { config . renderDropdownItem ( { item } ) }
180- </ DropdownMenuItem >
283+ </ button >
181284 )
182285 } )
183286 ) : (
184287 < div className = 'px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption' >
185288 No results
186289 </ div >
187- )
188- ) : (
189- < >
190- < DropdownMenuItem
191- onClick = { ( ) => {
192- setOpen ( false )
193- onFileSelect ( )
194- } }
195- >
196- < Paperclip className = 'h-[14px] w-[14px]' strokeWidth = { 2 } />
197- < span > Attachments</ span >
198- </ DropdownMenuItem >
199- < DropdownMenuSub >
200- < DropdownMenuSubTrigger >
201- < Sim className = 'h-[14px] w-[14px]' fill = 'currentColor' />
202- < span > Workspace</ span >
203- </ DropdownMenuSubTrigger >
204- < DropdownMenuSubContent >
205- { workflowTree . length > 0 && (
206- < DropdownMenuSub >
207- < DropdownMenuSubTrigger >
208- < div
209- className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
210- style = { {
211- backgroundColor : '#808080' ,
212- borderColor : '#80808060' ,
213- backgroundClip : 'padding-box' ,
214- } }
215- />
216- < span > Workflows</ span >
217- </ DropdownMenuSubTrigger >
218- < DropdownMenuSubContent >
219- < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
220- </ DropdownMenuSubContent >
221- </ DropdownMenuSub >
222- ) }
223- { availableResources
224- . filter ( ( { type } ) => type !== 'workflow' && type !== 'folder' )
225- . map ( ( { type, items } ) => {
226- if ( items . length === 0 ) return null
227- const config = getResourceConfig ( type )
228- const Icon = config . icon
229- return (
230- < DropdownMenuSub key = { type } >
231- < DropdownMenuSubTrigger >
232- < Icon className = 'h-[14px] w-[14px]' />
233- < span > { config . label } </ span >
234- </ DropdownMenuSubTrigger >
235- < DropdownMenuSubContent >
236- { items . map ( ( item ) => (
237- < DropdownMenuItem
238- key = { item . id }
239- onClick = { ( ) => {
240- handleSelect ( { type, id : item . id , title : item . name } )
241- } }
242- >
243- { config . renderDropdownItem ( { item } ) }
244- </ DropdownMenuItem >
245- ) ) }
246- </ DropdownMenuSubContent >
247- </ DropdownMenuSub >
248- )
249- } ) }
250- </ DropdownMenuSubContent >
251- </ DropdownMenuSub >
252- </ >
253- ) }
290+ ) ) }
254291 </ div >
255292 </ DropdownMenuContent >
256293 </ DropdownMenu >
0 commit comments