77 Popover ,
88 PopoverContent ,
99 PopoverItem ,
10- PopoverScrollArea ,
1110 PopoverSection ,
1211 PopoverTrigger ,
1312} from '@/components/emcn'
@@ -24,6 +23,7 @@ interface OutputSelectProps {
2423 disabled ?: boolean
2524 placeholder ?: string
2625 valueMode ?: 'id' | 'label'
26+ align ?: 'start' | 'end' | 'center'
2727}
2828
2929export function OutputSelect ( {
@@ -33,10 +33,13 @@ export function OutputSelect({
3333 disabled = false ,
3434 placeholder = 'Select outputs' ,
3535 valueMode = 'id' ,
36+ align = 'start' ,
3637} : OutputSelectProps ) {
3738 const [ open , setOpen ] = useState ( false )
39+ const [ highlightedIndex , setHighlightedIndex ] = useState ( - 1 )
3840 const triggerRef = useRef < HTMLDivElement > ( null )
3941 const popoverRef = useRef < HTMLDivElement > ( null )
42+ const contentRef = useRef < HTMLDivElement > ( null )
4043 const blocks = useWorkflowStore ( ( state ) => state . blocks )
4144 const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore ( )
4245 const subBlockValues = useSubBlockStore ( ( state ) =>
@@ -230,6 +233,13 @@ export function OutputSelect({
230233 return blockConfig ?. bgColor || '#2F55FF'
231234 }
232235
236+ /**
237+ * Flattened outputs for keyboard navigation
238+ */
239+ const flattenedOutputs = useMemo ( ( ) => {
240+ return Object . values ( groupedOutputs ) . flat ( )
241+ } , [ groupedOutputs ] )
242+
233243 /**
234244 * Handles output selection - toggle selection
235245 */
@@ -246,6 +256,75 @@ export function OutputSelect({
246256 onOutputSelect ( newSelectedOutputs )
247257 }
248258
259+ /**
260+ * Keyboard navigation handler
261+ */
262+ const handleKeyDown = ( e : React . KeyboardEvent ) => {
263+ if ( flattenedOutputs . length === 0 ) return
264+
265+ switch ( e . key ) {
266+ case 'ArrowDown' :
267+ e . preventDefault ( )
268+ setHighlightedIndex ( ( prev ) => {
269+ const next = prev < flattenedOutputs . length - 1 ? prev + 1 : 0
270+ return next
271+ } )
272+ break
273+
274+ case 'ArrowUp' :
275+ e . preventDefault ( )
276+ setHighlightedIndex ( ( prev ) => {
277+ const next = prev > 0 ? prev - 1 : flattenedOutputs . length - 1
278+ return next
279+ } )
280+ break
281+
282+ case 'Enter' :
283+ e . preventDefault ( )
284+ if ( highlightedIndex >= 0 && highlightedIndex < flattenedOutputs . length ) {
285+ handleOutputSelection ( flattenedOutputs [ highlightedIndex ] . label )
286+ }
287+ break
288+
289+ case 'Escape' :
290+ e . preventDefault ( )
291+ setOpen ( false )
292+ break
293+ }
294+ }
295+
296+ /**
297+ * Reset highlighted index when popover opens/closes
298+ */
299+ useEffect ( ( ) => {
300+ if ( open ) {
301+ // Find first selected item, or start at -1
302+ const firstSelectedIndex = flattenedOutputs . findIndex ( ( output ) => isSelectedValue ( output ) )
303+ setHighlightedIndex ( firstSelectedIndex >= 0 ? firstSelectedIndex : - 1 )
304+
305+ // Focus the content for keyboard navigation
306+ setTimeout ( ( ) => {
307+ contentRef . current ?. focus ( )
308+ } , 0 )
309+ } else {
310+ setHighlightedIndex ( - 1 )
311+ }
312+ } , [ open , flattenedOutputs ] )
313+
314+ /**
315+ * Scroll highlighted item into view
316+ */
317+ useEffect ( ( ) => {
318+ if ( highlightedIndex >= 0 && contentRef . current ) {
319+ const highlightedElement = contentRef . current . querySelector (
320+ `[data-option-index="${ highlightedIndex } "]`
321+ )
322+ if ( highlightedElement ) {
323+ highlightedElement . scrollIntoView ( { behavior : 'smooth' , block : 'nearest' } )
324+ }
325+ }
326+ } , [ highlightedIndex ] )
327+
249328 /**
250329 * Closes popover when clicking outside
251330 */
@@ -288,44 +367,57 @@ export function OutputSelect({
288367 < PopoverContent
289368 ref = { popoverRef }
290369 side = 'bottom'
291- align = 'start'
370+ align = { align }
292371 sideOffset = { 4 }
293- maxHeight = { 140 }
294- maxWidth = { 140 }
295- minWidth = { 140 }
296- onOpenAutoFocus = { ( e ) => e . preventDefault ( ) }
297- onCloseAutoFocus = { ( e ) => e . preventDefault ( ) }
372+ maxHeight = { 300 }
373+ maxWidth = { 300 }
374+ minWidth = { 200 }
375+ onKeyDown = { handleKeyDown }
376+ tabIndex = { 0 }
377+ style = { { outline : 'none' } }
298378 >
299- < PopoverScrollArea className = 'space-y-[2px]' >
300- { Object . entries ( groupedOutputs ) . map ( ( [ blockName , outputs ] ) => (
301- < div key = { blockName } >
302- < PopoverSection > { blockName } </ PopoverSection >
303-
304- < div className = 'flex flex-col gap-[2px]' >
305- { outputs . map ( ( output ) => (
306- < PopoverItem
307- key = { output . id }
308- active = { isSelectedValue ( output ) }
309- onClick = { ( ) => handleOutputSelection ( output . label ) }
310- >
311- < div
312- className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
313- style = { {
314- backgroundColor : getOutputColor ( output . blockId , output . blockType ) ,
315- } }
316- >
317- < span className = 'font-bold text-[10px] text-white' >
318- { blockName . charAt ( 0 ) . toUpperCase ( ) }
319- </ span >
320- </ div >
321- < span className = 'min-w-0 flex-1 truncate' > { output . path } </ span >
322- { isSelectedValue ( output ) && < Check className = 'h-3 w-3 flex-shrink-0' /> }
323- </ PopoverItem >
324- ) ) }
379+ < div ref = { contentRef } className = 'space-y-[2px]' >
380+ { Object . entries ( groupedOutputs ) . map ( ( [ blockName , outputs ] ) => {
381+ // Calculate the starting index for this group
382+ const startIndex = flattenedOutputs . findIndex ( ( o ) => o . blockName === blockName )
383+
384+ return (
385+ < div key = { blockName } >
386+ < PopoverSection > { blockName } </ PopoverSection >
387+
388+ < div className = 'flex flex-col gap-[2px]' >
389+ { outputs . map ( ( output , localIndex ) => {
390+ const globalIndex = startIndex + localIndex
391+ const isHighlighted = globalIndex === highlightedIndex
392+
393+ return (
394+ < PopoverItem
395+ key = { output . id }
396+ active = { isSelectedValue ( output ) || isHighlighted }
397+ data-option-index = { globalIndex }
398+ onClick = { ( ) => handleOutputSelection ( output . label ) }
399+ onMouseEnter = { ( ) => setHighlightedIndex ( globalIndex ) }
400+ >
401+ < div
402+ className = 'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
403+ style = { {
404+ backgroundColor : getOutputColor ( output . blockId , output . blockType ) ,
405+ } }
406+ >
407+ < span className = 'font-bold text-[10px] text-white' >
408+ { blockName . charAt ( 0 ) . toUpperCase ( ) }
409+ </ span >
410+ </ div >
411+ < span className = 'min-w-0 flex-1 truncate' > { output . path } </ span >
412+ { isSelectedValue ( output ) && < Check className = 'h-3 w-3 flex-shrink-0' /> }
413+ </ PopoverItem >
414+ )
415+ } ) }
416+ </ div >
325417 </ div >
326- </ div >
327- ) ) }
328- </ PopoverScrollArea >
418+ )
419+ } ) }
420+ </ div >
329421 </ PopoverContent >
330422 </ Popover >
331423 )
0 commit comments