improvement(popover): added keyboard nav to tag dropdown popover to iterate over parent & child items (#1903)

* improvement(popover): added keyboard nav to tag dropdown popover to iterate over parent & child items

* code cleanup

* ack PR comments
This commit is contained in:
Waleed
2025-11-11 15:53:34 -08:00
committed by GitHub
parent 77ba4d106f
commit 9b702c4793
23 changed files with 507 additions and 85 deletions

View File

@@ -28,7 +28,7 @@ import {
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -28,7 +28,7 @@ import {
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useTagSelection } from '@/hooks/use-tag-selection'

View File

@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/consts'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -7,7 +7,7 @@ import { Textarea } from '@/components/emcn/components/textarea/textarea'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -3,7 +3,7 @@ import { Input } from '@/components/emcn/components/input/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -9,7 +9,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'

View File

@@ -18,7 +18,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useMcpTools } from '@/hooks/use-mcp-tools'

View File

@@ -14,7 +14,7 @@ import type { ComboboxOption } from '@/components/emcn/components/combobox/combo
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -1,5 +1,5 @@
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
/**
* Props for the SubBlockDropdowns component.

View File

@@ -1,6 +1,6 @@
import type React from 'react'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'

View File

@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'

View File

@@ -0,0 +1 @@
export { KeyboardNavigationHandler } from './keyboard-navigation-handler'

View File

@@ -0,0 +1,256 @@
import { useEffect, useMemo } from 'react'
import { usePopoverContext } from '@/components/emcn'
import type { BlockTagGroup, NestedBlockTagGroup } from '../types'
/**
* Keyboard navigation handler component that uses popover context
* to enable folder navigation with arrow keys
*/
interface KeyboardNavigationHandlerProps {
visible: boolean
selectedIndex: number
setSelectedIndex: (index: number) => void
flatTagList: Array<{ tag: string; group?: BlockTagGroup }>
nestedBlockTagGroups: NestedBlockTagGroup[]
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
}
export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps> = ({
visible,
selectedIndex,
setSelectedIndex,
flatTagList,
nestedBlockTagGroups,
handleTagSelect,
}) => {
const { openFolder, closeFolder, isInFolder, currentFolder } = usePopoverContext()
const visibleIndices = useMemo(() => {
const indices: number[] = []
if (isInFolder && currentFolder) {
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
const folderId = `${group.blockId}-${nestedTag.key}`
if (folderId === currentFolder && nestedTag.children) {
// First, add the parent tag itself (so it's navigable as the first item)
if (nestedTag.parentTag) {
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
if (parentIdx >= 0) {
indices.push(parentIdx)
}
}
// Then add all children
for (const child of nestedTag.children) {
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
if (idx >= 0) {
indices.push(idx)
}
}
break
}
}
}
} else {
// We're at root level, show all non-child items
// (variables and parent tags, but not their children)
for (let i = 0; i < flatTagList.length; i++) {
const tag = flatTagList[i].tag
// Check if this is a child of a parent folder
let isChild = false
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
if (nestedTag.children) {
for (const child of nestedTag.children) {
if (child.fullTag === tag) {
isChild = true
break
}
}
}
if (isChild) break
}
if (isChild) break
}
if (!isChild) {
indices.push(i)
}
}
}
return indices
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups])
// Auto-select first visible item when entering/exiting folders
useEffect(() => {
if (!visible || visibleIndices.length === 0) return
if (!visibleIndices.includes(selectedIndex)) {
setSelectedIndex(visibleIndices[0])
}
}, [visible, isInFolder, currentFolder, visibleIndices, selectedIndex, setSelectedIndex])
useEffect(() => {
if (!visible || !flatTagList.length) return
// Helper to open a folder with proper selection callback and parent selection
const openFolderWithSelection = (
folderId: string,
folderTitle: string,
parentTag: string,
group: BlockTagGroup
) => {
const selectionCallback = () => handleTagSelect(parentTag, group)
// Find parent tag index (which is first in visible items when in folder)
let parentIndex = 0
for (const g of nestedBlockTagGroups) {
for (const nestedTag of g.nestedTags) {
if (nestedTag.parentTag === parentTag) {
const idx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
parentIndex = idx >= 0 ? idx : 0
break
}
}
}
openFolder(folderId, folderTitle, undefined, selectionCallback)
setSelectedIndex(parentIndex)
}
const handleKeyboardEvent = (e: KeyboardEvent) => {
const selected = flatTagList[selectedIndex]
if (!selected && e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
let currentFolderInfo: {
id: string
title: string
parentTag: string
group: BlockTagGroup
} | null = null
if (selected) {
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
if (
nestedTag.parentTag === selected.tag &&
nestedTag.children &&
nestedTag.children.length > 0
) {
currentFolderInfo = {
id: `${selected.group?.blockId}-${nestedTag.key}`,
title: nestedTag.display,
parentTag: nestedTag.parentTag,
group,
}
break
}
}
if (currentFolderInfo) break
}
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
if (currentVisibleIndex === -1) {
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex < visibleIndices.length - 1) {
setSelectedIndex(visibleIndices[currentVisibleIndex + 1])
}
}
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
if (currentVisibleIndex === -1) {
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex > 0) {
setSelectedIndex(visibleIndices[currentVisibleIndex - 1])
}
}
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
if (currentFolderInfo && !isInFolder) {
// It's a folder, open it
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
} else {
// Not a folder, select it
handleTagSelect(selected.tag, selected.group)
}
}
break
case 'ArrowRight':
if (currentFolderInfo && !isInFolder) {
e.preventDefault()
e.stopPropagation()
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
}
break
case 'ArrowLeft':
if (isInFolder) {
e.preventDefault()
e.stopPropagation()
closeFolder()
let firstRootIndex = 0
for (let i = 0; i < flatTagList.length; i++) {
const tag = flatTagList[i].tag
const isVariable = !tag.includes('.')
let isParent = false
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
if (nestedTag.parentTag === tag) {
isParent = true
break
}
}
if (isParent) break
}
if (isVariable || isParent) {
firstRootIndex = i
break
}
}
setSelectedIndex(firstRootIndex)
}
break
}
}
window.addEventListener('keydown', handleKeyboardEvent, true)
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
}, [
visible,
selectedIndex,
visibleIndices,
flatTagList,
nestedBlockTagGroups,
openFolder,
closeFolder,
isInFolder,
setSelectedIndex,
handleTagSelect,
])
return null
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { shallow } from 'zustand/shallow'
import {
Popover,
@@ -9,6 +9,7 @@ import {
PopoverItem,
PopoverScrollArea,
PopoverSection,
usePopoverContext,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
@@ -25,37 +26,11 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
import { KeyboardNavigationHandler } from './components/keyboard-navigation-handler'
import type { BlockTagGroup, NestedBlockTagGroup, NestedTag } from './types'
const logger = createLogger('TagDropdown')
/**
* Block tag group for organizing tags by block
*/
interface BlockTagGroup {
blockName: string
blockId: string
blockType: string
tags: string[]
distance: number
}
/**
* Nested tag structure for hierarchical display
*/
interface NestedTag {
key: string
display: string
fullTag?: string
children?: Array<{ key: string; display: string; fullTag: string }>
}
/**
* Block tag group with nested tag structure
*/
interface NestedBlockTagGroup extends BlockTagGroup {
nestedTags: NestedTag[]
}
/**
* Props for the TagDropdown component
*/
@@ -378,6 +353,55 @@ const TagIcon: React.FC<{ icon: string; color: string }> = ({ icon, color }) =>
</div>
)
/**
* Wrapper for PopoverBackButton that handles parent tag navigation
*/
const TagDropdownBackButton: React.FC<{
selectedIndex: number
setSelectedIndex: (index: number) => void
flatTagList: Array<{ tag: string; group?: BlockTagGroup }>
nestedBlockTagGroups: NestedBlockTagGroup[]
itemRefs: React.MutableRefObject<Map<number, HTMLElement>>
}> = ({ selectedIndex, setSelectedIndex, flatTagList, nestedBlockTagGroups, itemRefs }) => {
const { currentFolder } = usePopoverContext()
// Find parent tag info for current folder
const parentTagInfo = useMemo(() => {
if (!currentFolder) return null
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
const folderId = `${group.blockId}-${nestedTag.key}`
if (folderId === currentFolder && nestedTag.parentTag) {
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
return parentIdx >= 0 ? { index: parentIdx } : null
}
}
}
return null
}, [currentFolder, nestedBlockTagGroups, flatTagList])
if (!parentTagInfo) {
return <PopoverBackButton />
}
const isActive = parentTagInfo.index === selectedIndex
return (
<PopoverBackButton
folderTitleRef={(el) => {
if (el) {
itemRefs.current.set(parentTagInfo.index, el)
}
}}
folderTitleActive={isActive}
onFolderTitleMouseEnter={() => {
setSelectedIndex(parentTagInfo.index)
}}
/>
)
}
/**
* TagDropdown component that displays available tags (variables and block outputs)
* for selection in input fields. Uses the Popover component system for consistent styling.
@@ -395,6 +419,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
inputRef,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const itemRefs = useRef<Map<number, HTMLElement>>(new Map())
const { blocks, edges, loops, parallels } = useWorkflowStore(
(state) => ({
@@ -1053,11 +1078,23 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
})
Object.entries(groupedTags).forEach(([parent, children]) => {
nestedTags.push({
key: parent,
display: parent,
children: children,
})
const firstChildTag = children[0]?.fullTag
if (firstChildTag) {
const tagParts = firstChildTag.split('.')
const parentTag = `${tagParts[0]}.${parent}`
nestedTags.push({
key: parent,
display: parent,
parentTag: parentTag,
children: children,
})
} else {
nestedTags.push({
key: parent,
display: parent,
children: children,
})
}
})
directTags.forEach((directTag) => {
@@ -1080,15 +1117,36 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
nestedBlockTagGroups.forEach((group) => {
group.nestedTags.forEach((nestedTag) => {
if (nestedTag.parentTag) {
list.push({ tag: nestedTag.parentTag, group })
}
if (nestedTag.fullTag) {
list.push({ tag: nestedTag.fullTag, group })
}
if (nestedTag.children) {
nestedTag.children.forEach((child) => {
list.push({ tag: child.fullTag, group })
})
}
})
})
return list
}, [variableTags, nestedBlockTagGroups])
// Auto-scroll selected item into view
useEffect(() => {
if (!visible || selectedIndex < 0) return
const element = itemRefs.current.get(selectedIndex)
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}, [selectedIndex, visible])
const handleTagSelect = useCallback(
(tag: string, blockGroup?: BlockTagGroup) => {
let liveCursor = cursorPosition
@@ -1192,27 +1250,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
useEffect(() => {
if (visible) {
const handleKeyboardEvent = (e: KeyboardEvent) => {
if (!flatTagList.length) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => Math.min(prev + 1, flatTagList.length - 1))
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (selectedIndex >= 0 && selectedIndex < flatTagList.length) {
const selected = flatTagList[selectedIndex]
handleTagSelect(selected.tag, selected.group)
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
@@ -1224,7 +1262,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
window.addEventListener('keydown', handleKeyboardEvent, true)
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
}
}, [visible, selectedIndex, flatTagList, handleTagSelect, onClose])
}, [visible, onClose])
if (!visible || tags.length === 0 || flatTagList.length === 0) return null
@@ -1257,6 +1295,14 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}}
/>
</PopoverAnchor>
<KeyboardNavigationHandler
visible={visible}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
flatTagList={flatTagList}
nestedBlockTagGroups={nestedBlockTagGroups}
handleTagSelect={handleTagSelect}
/>
<PopoverContent
maxHeight={240}
className='min-w-[280px]'
@@ -1266,7 +1312,13 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverBackButton />
<TagDropdownBackButton
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
flatTagList={flatTagList}
nestedBlockTagGroups={nestedBlockTagGroups}
itemRefs={itemRefs}
/>
<PopoverScrollArea>
{flatTagList.length === 0 ? (
<div className='px-[6px] py-[8px] text-[#FFFFFF]/60 text-[12px]'>
@@ -1277,7 +1329,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
{variableTags.length > 0 && (
<>
<PopoverSection>Variables</PopoverSection>
{variableTags.map((tag: string, index: number) => {
{variableTags.map((tag: string) => {
const variableInfo = variableInfoMap?.[tag] || null
const globalIndex = flatTagList.findIndex((item) => item.tag === tag)
@@ -1294,6 +1346,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
e.stopPropagation()
handleTagSelect(tag)
}}
ref={(el) => {
if (el && globalIndex >= 0) {
itemRefs.current.set(globalIndex, el)
}
}}
>
<TagIcon icon='V' color={BLOCK_COLORS.VARIABLE} />
<span className='flex-1 truncate'>
@@ -1333,15 +1390,31 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (hasChildren) {
const folderId = `${group.blockId}-${nestedTag.key}`
const parentGlobalIndex = nestedTag.parentTag
? flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
: -1
return (
<PopoverFolder
key={folderId}
id={folderId}
title={nestedTag.display}
icon={<TagIcon icon={tagIcon} color={blockColor} />}
active={parentGlobalIndex === selectedIndex && parentGlobalIndex >= 0}
onSelect={() => {
if (nestedTag.parentTag) {
handleTagSelect(nestedTag.parentTag, group)
}
}}
onMouseEnter={() => {
// Clear selection when hovering folder to prevent highlighting items
setSelectedIndex(-1)
if (parentGlobalIndex >= 0) {
setSelectedIndex(parentGlobalIndex)
}
}}
ref={(el) => {
if (el && parentGlobalIndex >= 0) {
itemRefs.current.set(parentGlobalIndex, el)
}
}}
>
{nestedTag.children!.map((child) => {
@@ -1383,6 +1456,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
e.stopPropagation()
handleTagSelect(child.fullTag, group)
}}
ref={(el) => {
if (el && childGlobalIndex >= 0) {
itemRefs.current.set(childGlobalIndex, el)
}
}}
>
<TagIcon icon={tagIcon} color={blockColor} />
<span className='flex-1 truncate'>{child.display}</span>
@@ -1457,6 +1535,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
handleTagSelect(nestedTag.fullTag, group)
}
}}
ref={(el) => {
if (el && globalIndex >= 0) {
itemRefs.current.set(globalIndex, el)
}
}}
>
<TagIcon icon={displayIcon} color={blockColor} />
<span className='flex-1 truncate'>{nestedTag.display}</span>

View File

@@ -0,0 +1,28 @@
/**
* Block tag group for organizing tags by block
*/
export interface BlockTagGroup {
blockName: string
blockId: string
blockType: string
tags: string[]
distance: number
}
/**
* Nested tag structure for hierarchical display
*/
export interface NestedTag {
key: string
display: string
fullTag?: string
parentTag?: string // Tag for the parent object when it has children
children?: Array<{ key: string; display: string; fullTag: string }>
}
/**
* Block tag group with nested tag structure
*/
export interface NestedBlockTagGroup extends BlockTagGroup {
nestedTags: NestedTag[]
}

View File

@@ -31,7 +31,7 @@ import {
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'

View File

@@ -17,7 +17,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useVariablesStore } from '@/stores/panel/variables/store'

View File

@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'

View File

@@ -3,7 +3,7 @@
import { ChevronUp } from 'lucide-react'
import SimpleCodeEditor from 'react-simple-code-editor'
import { Code as CodeEditor, Combobox, getCodeEditorProps, Input, Label } from '@/components/emcn'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ConnectedBlock } from '../../hooks/use-block-connections'
import { useSubflowEditor } from '../../hooks/use-subflow-editor'

View File

@@ -5,7 +5,7 @@ import {
SYSTEM_REFERENCE_PREFIXES,
splitReferenceSegment,
} from '@/lib/workflows/references'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { normalizeBlockName } from '@/stores/workflows/utils'

View File

@@ -40,6 +40,7 @@ export {
PopoverSection,
type PopoverSectionProps,
PopoverTrigger,
usePopoverContext,
} from './popover/popover'
export { Textarea } from './textarea/textarea'
export { Tooltip } from './tooltip/tooltip'

View File

@@ -84,11 +84,17 @@ const POPOVER_ITEM_HOVER_CLASSES = {
type PopoverVariant = 'default' | 'primary'
interface PopoverContextValue {
openFolder: (id: string, title: string, onLoad?: () => void | Promise<void>) => void
openFolder: (
id: string,
title: string,
onLoad?: () => void | Promise<void>,
onSelect?: () => void
) => void
closeFolder: () => void
currentFolder: string | null
isInFolder: boolean
folderTitle: string | null
onFolderSelect: (() => void) | null
variant: PopoverVariant
searchQuery: string
setSearchQuery: (query: string) => void
@@ -126,12 +132,14 @@ export interface PopoverProps extends PopoverPrimitive.PopoverProps {
const Popover: React.FC<PopoverProps> = ({ children, variant = 'default', ...props }) => {
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
const [folderTitle, setFolderTitle] = React.useState<string | null>(null)
const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const openFolder = React.useCallback(
(id: string, title: string, onLoad?: () => void | Promise<void>) => {
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
setCurrentFolder(id)
setFolderTitle(title)
setOnFolderSelect(onSelect ?? null)
if (onLoad) {
void Promise.resolve(onLoad())
}
@@ -142,6 +150,7 @@ const Popover: React.FC<PopoverProps> = ({ children, variant = 'default', ...pro
const closeFolder = React.useCallback(() => {
setCurrentFolder(null)
setFolderTitle(null)
setOnFolderSelect(null)
}, [])
const contextValue: PopoverContextValue = React.useMemo(
@@ -151,11 +160,12 @@ const Popover: React.FC<PopoverProps> = ({ children, variant = 'default', ...pro
currentFolder,
isInFolder: currentFolder !== null,
folderTitle,
onFolderSelect,
variant,
searchQuery,
setSearchQuery,
}),
[openFolder, closeFolder, currentFolder, folderTitle, variant, searchQuery]
[openFolder, closeFolder, currentFolder, folderTitle, onFolderSelect, variant, searchQuery]
)
return (
@@ -426,6 +436,10 @@ export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivEle
* Function to call when folder is opened (for lazy loading)
*/
onOpen?: () => void | Promise<void>
/**
* Function to call when the folder title is selected (from within the folder view)
*/
onSelect?: () => void
/**
* Children to render when folder is open
*/
@@ -449,7 +463,7 @@ export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivEle
* ```
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
({ className, id, title, icon, onOpen, children, active, ...props }, ref) => {
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant } = usePopoverContext()
// Don't render if we're in a different folder
@@ -462,6 +476,12 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
return <>{children}</>
}
// Handle click anywhere on folder item
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
openFolder(id, title, onOpen, onSelect)
}
// Otherwise, render as a clickable folder item
return (
<div
@@ -474,7 +494,7 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
role='menuitem'
aria-haspopup='true'
aria-expanded={false}
onClick={() => openFolder(id, title, onOpen)}
onClick={handleClick}
{...props}
>
{icon}
@@ -487,7 +507,20 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
PopoverFolder.displayName = 'PopoverFolder'
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {}
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Ref callback for the folder title element (when selectable)
*/
folderTitleRef?: (el: HTMLElement | null) => void
/**
* Whether the folder title is currently active/selected
*/
folderTitleActive?: boolean
/**
* Callback when mouse enters the folder title
*/
onFolderTitleMouseEnter?: () => void
}
/**
* Back button component that appears when inside a folder.
@@ -504,8 +537,8 @@ export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElem
* ```
*/
const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProps>(
({ className, ...props }, ref) => {
const { isInFolder, closeFolder, folderTitle, variant } = usePopoverContext()
({ className, folderTitleRef, folderTitleActive, onFolderTitleMouseEnter, ...props }, ref) => {
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant } = usePopoverContext()
if (!isInFolder) {
return null
@@ -523,7 +556,26 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
<ChevronLeft className='h-3 w-3' />
<span>Back</span>
</div>
{folderTitle && (
{folderTitle && onFolderSelect && (
<div
ref={folderTitleRef}
className={cn(
POPOVER_ITEM_BASE_CLASSES,
folderTitleActive
? POPOVER_ITEM_ACTIVE_CLASSES[variant]
: POPOVER_ITEM_HOVER_CLASSES[variant]
)}
role='button'
onClick={(e) => {
e.stopPropagation()
onFolderSelect()
}}
onMouseEnter={onFolderTitleMouseEnter}
>
<span>{folderTitle}</span>
</div>
)}
{folderTitle && !onFolderSelect && (
<div className='px-[6px] py-[4px] font-base text-[#AEAEAE] text-[12px] dark:text-[#AEAEAE]'>
{folderTitle}
</div>
@@ -605,4 +657,5 @@ export {
PopoverFolder,
PopoverBackButton,
PopoverSearch,
usePopoverContext,
}