From 9cae5a93b9b502c86a23d71d00d99bbe0e9ecdad Mon Sep 17 00:00:00 2001 From: waleedlatif Date: Tue, 29 Jul 2025 23:30:07 -0700 Subject: [PATCH 1/2] fixed search modal keyboard nav --- .../components/search-modal/search-modal.tsx | 988 +++++++++++------- 1 file changed, 586 insertions(+), 402 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx index 11812463695..bb20e8fd10c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx @@ -85,6 +85,258 @@ interface DocItem { type: 'main' | 'block' | 'tool' } +// Navigation types for the new system +interface NavigationPosition { + sectionIndex: number + itemIndex: number +} + +interface NavigationSection { + id: string + name: string + type: 'grid' | 'list' + items: any[] + gridCols?: number // How many columns per row for grid sections +} + +// Custom hook for navigation logic +function useSearchNavigation(sections: NavigationSection[], open: boolean) { + const [position, setPosition] = useState({ sectionIndex: 0, itemIndex: 0 }) + const scrollRefs = useRef>(new Map()) + const lastItemIndex = useRef>(new Map()) // Store last item index for each section + + // Reset position when modal opens or sections change + useEffect(() => { + if (open) { + setPosition({ sectionIndex: 0, itemIndex: 0 }) + } + }, [open, sections]) + + // Get current selected item + const getCurrentItem = useCallback(() => { + const section = sections[position.sectionIndex] + if (!section || position.itemIndex >= section.items.length) return null + + return { + section, + item: section.items[position.itemIndex], + position, + } + }, [sections, position]) + + // Navigate with keyboard + const navigate = useCallback( + (direction: 'up' | 'down' | 'left' | 'right') => { + setPosition((prev) => { + const section = sections[prev.sectionIndex] + if (!section) return prev + + // Debug logging + console.log( + 'Navigate:', + direction, + 'Current:', + prev, + 'Section:', + section.id, + 'Items:', + section.items.length + ) + + switch (direction) { + case 'down': + if (section.type === 'grid' && section.gridCols) { + // CSS Grid with grid-flow-col and 2 rows means items flow column-wise + // Index pattern: 0=R0C0, 1=R1C0, 2=R0C1, 3=R1C1, 4=R0C2, 5=R1C2... + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + console.log('Grid down:', { + itemIndex: prev.itemIndex, + totalRows, + currentCol, + currentRow, + }) + + // Try to move down one row in the same column + if (currentRow < totalRows - 1) { + const nextIndex = currentCol * totalRows + (currentRow + 1) + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + // For list navigation, move to next item within the list + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + // Move to next section + if (prev.sectionIndex < sections.length - 1) { + const nextSection = sections[prev.sectionIndex + 1] + + // Store current item index for this section + lastItemIndex.current.set(section.id, prev.itemIndex) + + // Check if we have a remembered position for the next section + const rememberedIndex = lastItemIndex.current.get(nextSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, nextSection.items.length - 1) + : 0 + + console.log('Section transition down:', { + from: section.id, + to: nextSection.id, + storedIndex: prev.itemIndex, + rememberedIndex, + targetIndex, + }) + + return { sectionIndex: prev.sectionIndex + 1, itemIndex: targetIndex } + } + return prev + + case 'up': + if (section.type === 'grid' && section.gridCols) { + // CSS Grid with grid-flow-col and 2 rows means items flow column-wise + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + console.log('Grid up:', { + itemIndex: prev.itemIndex, + totalRows, + currentCol, + currentRow, + }) + + // Try to move up one row in the same column + if (currentRow > 0) { + const prevIndex = currentCol * totalRows + (currentRow - 1) + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + // For list navigation, move to previous item within the list + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + // Move to previous section + if (prev.sectionIndex > 0) { + const prevSection = sections[prev.sectionIndex - 1] + + // Store current item index for this section + lastItemIndex.current.set(section.id, prev.itemIndex) + + // Check if we have a remembered position for the previous section + const rememberedIndex = lastItemIndex.current.get(prevSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, prevSection.items.length - 1) + : prevSection.items.length - 1 // Default to last item + + console.log('Section transition up:', { + from: section.id, + to: prevSection.id, + storedIndex: prev.itemIndex, + rememberedIndex, + targetIndex, + }) + + return { sectionIndex: prev.sectionIndex - 1, itemIndex: targetIndex } + } + return prev + + case 'right': + if (section.type === 'grid' && section.gridCols) { + // CSS Grid with grid-flow-col: move right means next column, same row + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + const totalCols = Math.ceil(section.items.length / totalRows) + + // Can we move right to the next column? + if (currentCol < totalCols - 1) { + const nextIndex = (currentCol + 1) * totalRows + currentRow + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + // List navigation - just move to next item + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + return prev + + case 'left': + if (section.type === 'grid' && section.gridCols) { + // CSS Grid with grid-flow-col: move left means previous column, same row + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + // Can we move left to the previous column? + if (currentCol > 0) { + const prevIndex = (currentCol - 1) * totalRows + currentRow + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + // List navigation - move to previous item + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + return prev + + default: + return prev + } + }) + }, + [sections] + ) + + // Scroll item into view + const scrollIntoView = useCallback(() => { + const current = getCurrentItem() + if (!current) return + + const container = scrollRefs.current.get(current.section.id) + if (!container) return + + const itemSelector = `[data-nav-item="${current.section.id}-${current.position.itemIndex}"]` + const element = container.querySelector(itemSelector) as HTMLElement + if (!element) return + + // Use modern scrollIntoView with proper options + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', // This ensures horizontal centering + }) + }, [getCurrentItem]) + + // Trigger scroll when position changes + useEffect(() => { + if (open) { + // Small delay to ensure DOM is updated + const timer = setTimeout(scrollIntoView, 10) + return () => clearTimeout(timer) + } + }, [position, open, scrollIntoView]) + + return { + position, + navigate, + getCurrentItem, + scrollRefs, + } +} + export function SearchModal({ open, onOpenChange, @@ -95,7 +347,6 @@ export function SearchModal({ isOnWorkflowPage = false, }: SearchModalProps) { const [searchQuery, setSearchQuery] = useState('') - const [selectedIndex, setSelectedIndex] = useState(0) const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string @@ -108,37 +359,6 @@ export function SearchModal({ setLocalTemplates(templates) }, [templates]) - // Refs for synchronized scrolling - const blocksRow1Ref = useRef(null) - const blocksRow2Ref = useRef(null) - const toolsRow1Ref = useRef(null) - const toolsRow2Ref = useRef(null) - - // Synchronized scrolling functions - const handleBlocksRow1Scroll = useCallback(() => { - if (blocksRow1Ref.current && blocksRow2Ref.current) { - blocksRow2Ref.current.scrollLeft = blocksRow1Ref.current.scrollLeft - } - }, []) - - const handleBlocksRow2Scroll = useCallback(() => { - if (blocksRow1Ref.current && blocksRow2Ref.current) { - blocksRow1Ref.current.scrollLeft = blocksRow2Ref.current.scrollLeft - } - }, []) - - const handleToolsRow1Scroll = useCallback(() => { - if (toolsRow1Ref.current && toolsRow2Ref.current) { - toolsRow2Ref.current.scrollLeft = toolsRow1Ref.current.scrollLeft - } - }, []) - - const handleToolsRow2Scroll = useCallback(() => { - if (toolsRow1Ref.current && toolsRow2Ref.current) { - toolsRow1Ref.current.scrollLeft = toolsRow2Ref.current.scrollLeft - } - }, []) - // Get all available blocks - only when on workflow page const blocks = useMemo(() => { if (!isOnWorkflowPage) return [] @@ -285,55 +505,73 @@ export function SearchModal({ return docs.filter((doc) => doc.name.toLowerCase().includes(query)) }, [docs, searchQuery]) - // Create flattened list of navigatable items for keyboard navigation - const navigatableItems = useMemo(() => { - const items: Array<{ - type: 'workspace' | 'workflow' | 'page' | 'doc' - data: any - section: string - }> = [] - - // Add workspaces - filteredWorkspaces.forEach((workspace) => { - items.push({ type: 'workspace', data: workspace, section: 'Workspaces' }) - }) - - // Add workflows - filteredWorkflows.forEach((workflow) => { - items.push({ type: 'workflow', data: workflow, section: 'Workflows' }) - }) - - // Add pages - filteredPages.forEach((page) => { - items.push({ type: 'page', data: page, section: 'Pages' }) - }) - - // Add docs - filteredDocs.forEach((doc) => { - items.push({ type: 'doc', data: doc, section: 'Docs' }) - }) - - return items - }, [filteredWorkspaces, filteredWorkflows, filteredPages, filteredDocs]) + // Create navigation sections + const navigationSections = useMemo((): NavigationSection[] => { + const sections: NavigationSection[] = [] + + // Add blocks section + if (filteredBlocks.length > 0) { + sections.push({ + id: 'blocks', + name: 'Blocks', + type: 'grid', + items: filteredBlocks, + gridCols: 4, // 4 items per row + }) + } - // Reset selected index when items change or modal opens - useEffect(() => { - setSelectedIndex(0) - }, [navigatableItems, open]) + // Add tools section + if (filteredTools.length > 0) { + sections.push({ + id: 'tools', + name: 'Tools', + type: 'grid', + items: filteredTools, + gridCols: 4, // 4 items per row + }) + } - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && open) { - onOpenChange(false) - } + // Add templates section + if (filteredTemplates.length > 0) { + sections.push({ + id: 'templates', + name: 'Templates', + type: 'grid', + items: filteredTemplates, + gridCols: 2, // 2 templates per row + }) } - if (open) { - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) + // Add list sections + const listItems = [ + ...filteredWorkspaces.map((item) => ({ type: 'workspace', data: item })), + ...filteredWorkflows.map((item) => ({ type: 'workflow', data: item })), + ...filteredPages.map((item) => ({ type: 'page', data: item })), + ...filteredDocs.map((item) => ({ type: 'doc', data: item })), + ] + + if (listItems.length > 0) { + sections.push({ + id: 'list', + name: 'Navigation', + type: 'list', + items: listItems, + }) } - }, [open, onOpenChange]) + + return sections + }, [ + filteredBlocks, + filteredTools, + filteredTemplates, + filteredWorkspaces, + filteredWorkflows, + filteredPages, + filteredDocs, + ]) + + // Use the navigation hook + const { navigate, getCurrentItem, scrollRefs } = useSearchNavigation(navigationSections, open) // Clear search when modal closes useEffect(() => { @@ -342,14 +580,11 @@ export function SearchModal({ } }, [open]) - // Handle block/tool click (same as toolbar interaction) + // Handle block/tool click const handleBlockClick = useCallback( (blockType: string) => { - // Dispatch a custom event to be caught by the workflow component const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: blockType, - }, + detail: { type: blockType }, }) window.dispatchEvent(event) onOpenChange(false) @@ -360,7 +595,6 @@ export function SearchModal({ // Handle page navigation const handlePageClick = useCallback( (href: string) => { - // External links open in new tab if (href.startsWith('http')) { window.open(href, '_blank', 'noopener,noreferrer') } else { @@ -371,7 +605,7 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle workflow/workspace navigation (same as page navigation) + // Handle workflow/workspace navigation const handleNavigationClick = useCallback( (href: string) => { router.push(href) @@ -383,7 +617,6 @@ export function SearchModal({ // Handle docs navigation const handleDocsClick = useCallback( (href: string) => { - // External links open in new tab if (href.startsWith('http')) { window.open(href, '_blank', 'noopener,noreferrer') } else { @@ -394,72 +627,18 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle page navigation shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle shortcuts when modal is open - if (!open) return - - const isMac = - typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0 - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey - - // Check if this is one of our specific shortcuts - const isOurShortcut = - isModifierPressed && - e.shiftKey && - (e.key.toLowerCase() === 'l' || e.key.toLowerCase() === 'k') - - // Don't trigger other shortcuts if user is typing in the search input - // But allow our specific shortcuts to pass through - if (!isOurShortcut) { - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) return - } - - if (isModifierPressed && e.shiftKey) { - // Command+Shift+L - Navigate to Logs - if (e.key.toLowerCase() === 'l') { - e.preventDefault() - handlePageClick(`/workspace/${workspaceId}/logs`) - } - // Command+Shift+K - Navigate to Knowledge - else if (e.key.toLowerCase() === 'k') { - e.preventDefault() - handlePageClick(`/workspace/${workspaceId}/knowledge`) - } - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [open, handlePageClick, workspaceId]) + // Handle item selection + const handleItemSelection = useCallback(() => { + const current = getCurrentItem() + if (!current) return - // Handle template usage callback (closes modal after template is used) - const handleTemplateUsed = useCallback(() => { - onOpenChange(false) - }, [onOpenChange]) - - // Handle star change callback from template card - const handleStarChange = useCallback( - (templateId: string, isStarred: boolean, newStarCount: number) => { - setLocalTemplates((prevTemplates) => - prevTemplates.map((template) => - template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template - ) - ) - }, - [] - ) + const { section, item } = current - // Handle item selection based on type - const handleItemSelection = useCallback( - (item: (typeof navigatableItems)[0]) => { + if (section.id === 'blocks' || section.id === 'tools') { + handleBlockClick(item.type) + } else if (section.id === 'templates') { + onOpenChange(false) + } else if (section.id === 'list') { switch (item.type) { case 'workspace': if (item.data.isCurrent) { @@ -482,9 +661,15 @@ export function SearchModal({ handleDocsClick(item.data.href) break } - }, - [handleNavigationClick, handlePageClick, handleDocsClick, onOpenChange] - ) + } + }, [ + getCurrentItem, + handleBlockClick, + handleNavigationClick, + handlePageClick, + handleDocsClick, + onOpenChange, + ]) // Handle keyboard navigation useEffect(() => { @@ -494,18 +679,23 @@ export function SearchModal({ switch (e.key) { case 'ArrowDown': e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, navigatableItems.length - 1)) + navigate('down') break case 'ArrowUp': e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) + navigate('up') + break + case 'ArrowRight': + e.preventDefault() + navigate('right') + break + case 'ArrowLeft': + e.preventDefault() + navigate('left') break case 'Enter': e.preventDefault() - if (navigatableItems.length > 0 && selectedIndex < navigatableItems.length) { - const selectedItem = navigatableItems[selectedIndex] - handleItemSelection(selectedItem) - } + handleItemSelection() break case 'Escape': onOpenChange(false) @@ -515,30 +705,28 @@ export function SearchModal({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, selectedIndex, navigatableItems, onOpenChange, handleItemSelection]) + }, [open, navigate, handleItemSelection, onOpenChange]) - // Helper function to check if an item is selected - const isItemSelected = useCallback( - (item: any, itemType: string) => { - if (navigatableItems.length === 0 || selectedIndex >= navigatableItems.length) return false - const selectedItem = navigatableItems[selectedIndex] - return selectedItem.type === itemType && selectedItem.data.id === item.id + // Handle template star changes + const handleStarChange = useCallback( + (templateId: string, isStarred: boolean, newStarCount: number) => { + setLocalTemplates((prevTemplates) => + prevTemplates.map((template) => + template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template + ) + ) }, - [navigatableItems, selectedIndex] + [] ) - // Scroll selected item into view - useEffect(() => { - if (selectedIndex >= 0 && navigatableItems.length > 0) { - const selectedItem = navigatableItems[selectedIndex] - const itemElement = document.querySelector( - `[data-search-item="${selectedItem.type}-${selectedItem.data.id}"]` - ) - if (itemElement) { - itemElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - } - } - }, [selectedIndex, navigatableItems]) + // Helper to check if item is selected + const isItemSelected = useCallback( + (sectionId: string, itemIndex: number) => { + const current = getCurrentItem() + return current?.section.id === sectionId && current.position.itemIndex === itemIndex + }, + [getCurrentItem] + ) // Render skeleton cards for loading state const renderSkeletonCards = () => { @@ -560,6 +748,7 @@ export function SearchModal({ Search + {/* Header with search input */}
@@ -584,61 +773,40 @@ export function SearchModal({

Blocks

-
- {/* First row */} +
{ + if (el) scrollRefs.current.set('blocks', el) + }} + className='scrollbar-none overflow-x-auto pr-6 pb-1 pl-6' + style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} + >
- {filteredBlocks - .slice(0, Math.ceil(filteredBlocks.length / 2)) - .map((block) => ( - - ))} + +
+ + {block.name} + + + ))}
- {/* Second row */} - {filteredBlocks.length > Math.ceil(filteredBlocks.length / 2) && ( -
- {filteredBlocks.slice(Math.ceil(filteredBlocks.length / 2)).map((block) => ( - - ))} -
- )}
)} @@ -649,19 +817,27 @@ export function SearchModal({

Tools

-
- {/* First row */} +
{ + if (el) scrollRefs.current.set('tools', el) + }} + className='scrollbar-none overflow-x-auto pr-6 pb-1 pl-6' + style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} + >
- {filteredTools.slice(0, Math.ceil(filteredTools.length / 2)).map((tool) => ( + {filteredTools.map((tool, index) => ( - ))} -
- )}
)} @@ -713,13 +862,22 @@ export function SearchModal({ Templates
{ + if (el) scrollRefs.current.set('templates', el) + }} className='scrollbar-none flex gap-4 overflow-x-auto pr-6 pb-1 pl-6' style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} > {loading ? renderSkeletonCards() - : filteredTemplates.map((template) => ( -
+ : filteredTemplates.map((template, index) => ( +
onOpenChange(false)} onStarChange={handleStarChange} />
@@ -740,142 +898,168 @@ export function SearchModal({
)} - {/* Workspaces Section */} - {filteredWorkspaces.length > 0 && ( -
-

- Workspaces -

-
- {filteredWorkspaces.map((workspace) => ( - - ))} -
-
- )} - - {/* Workflows Section */} - {filteredWorkflows.length > 0 && ( -
-

- Workflows -

-
- {filteredWorkflows.map((workflow) => ( - - ))} -
-
- )} - - {/* Pages Section */} - {filteredPages.length > 0 && ( -
-

- Pages -

-
- {filteredPages.map((page) => ( - - ))} -
-
- )} - - {/* Docs Section */} - {filteredDocs.length > 0 && ( -
-

- Docs -

-
- {filteredDocs.map((doc) => ( - - ))} -
+ {/* List sections (Workspaces, Workflows, Pages, Docs) */} + {navigationSections.find((s) => s.id === 'list') && ( +
{ + if (el) scrollRefs.current.set('list', el) + }} + > + {/* Workspaces */} + {filteredWorkspaces.length > 0 && ( +
+

+ Workspaces +

+
+ {filteredWorkspaces.map((workspace, workspaceIndex) => { + const globalIndex = workspaceIndex + return ( + + ) + })} +
+
+ )} + + {/* Workflows */} + {filteredWorkflows.length > 0 && ( +
+

+ Workflows +

+
+ {filteredWorkflows.map((workflow, workflowIndex) => { + const globalIndex = filteredWorkspaces.length + workflowIndex + return ( + + ) + })} +
+
+ )} + + {/* Pages */} + {filteredPages.length > 0 && ( +
+

+ Pages +

+
+ {filteredPages.map((page, pageIndex) => { + const globalIndex = + filteredWorkspaces.length + filteredWorkflows.length + pageIndex + return ( + + ) + })} +
+
+ )} + + {/* Docs */} + {filteredDocs.length > 0 && ( +
+

+ Docs +

+
+ {filteredDocs.map((doc, docIndex) => { + const globalIndex = + filteredWorkspaces.length + + filteredWorkflows.length + + filteredPages.length + + docIndex + return ( + + ) + })} +
+
+ )}
)} From 2ec029cc4e9b1c2018940ee3d3add0d6eb6a34b6 Mon Sep 17 00:00:00 2001 From: waleedlatif Date: Tue, 29 Jul 2025 23:50:55 -0700 Subject: [PATCH 2/2] break down file --- .../hooks/use-search-navigation.ts | 171 +++++++++++ .../components/search-modal/search-modal.tsx | 286 +----------------- 2 files changed, 183 insertions(+), 274 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts new file mode 100644 index 00000000000..2249e3591ab --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NavigationPosition, NavigationSection } from '../search-modal' + +export function useSearchNavigation(sections: NavigationSection[], open: boolean) { + const [position, setPosition] = useState({ sectionIndex: 0, itemIndex: 0 }) + const scrollRefs = useRef>(new Map()) + const lastItemIndex = useRef>(new Map()) + + useEffect(() => { + if (open) { + setPosition({ sectionIndex: 0, itemIndex: 0 }) + } + }, [open, sections]) + + const getCurrentItem = useCallback(() => { + const section = sections[position.sectionIndex] + if (!section || position.itemIndex >= section.items.length) return null + + return { + section, + item: section.items[position.itemIndex], + position, + } + }, [sections, position]) + + const navigate = useCallback( + (direction: 'up' | 'down' | 'left' | 'right') => { + setPosition((prev) => { + const section = sections[prev.sectionIndex] + if (!section) return prev + + switch (direction) { + case 'down': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentRow < totalRows - 1) { + const nextIndex = currentCol * totalRows + (currentRow + 1) + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + if (prev.sectionIndex < sections.length - 1) { + const nextSection = sections[prev.sectionIndex + 1] + + lastItemIndex.current.set(section.id, prev.itemIndex) + + const rememberedIndex = lastItemIndex.current.get(nextSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, nextSection.items.length - 1) + : 0 + + return { sectionIndex: prev.sectionIndex + 1, itemIndex: targetIndex } + } + return prev + + case 'up': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentRow > 0) { + const prevIndex = currentCol * totalRows + (currentRow - 1) + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + if (prev.sectionIndex > 0) { + const prevSection = sections[prev.sectionIndex - 1] + + lastItemIndex.current.set(section.id, prev.itemIndex) + + const rememberedIndex = lastItemIndex.current.get(prevSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, prevSection.items.length - 1) + : prevSection.items.length - 1 + + return { sectionIndex: prev.sectionIndex - 1, itemIndex: targetIndex } + } + return prev + + case 'right': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + const totalCols = Math.ceil(section.items.length / totalRows) + + if (currentCol < totalCols - 1) { + const nextIndex = (currentCol + 1) * totalRows + currentRow + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + return prev + + case 'left': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentCol > 0) { + const prevIndex = (currentCol - 1) * totalRows + currentRow + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + return prev + + default: + return prev + } + }) + }, + [sections] + ) + + const scrollIntoView = useCallback(() => { + const current = getCurrentItem() + if (!current) return + + const container = scrollRefs.current.get(current.section.id) + if (!container) return + + const itemSelector = `[data-nav-item="${current.section.id}-${current.position.itemIndex}"]` + const element = container.querySelector(itemSelector) as HTMLElement + if (!element) return + + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }) + }, [getCurrentItem]) + + useEffect(() => { + if (open) { + const timer = setTimeout(scrollIntoView, 10) + return () => clearTimeout(timer) + } + }, [position, open, scrollIntoView]) + + return { + position, + navigate, + getCurrentItem, + scrollRefs, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx index bb20e8fd10c..63633e4e046 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import { BookOpen, Building2, LibraryBig, ScrollText, Search, Shapes, Workflow } from 'lucide-react' @@ -13,8 +13,9 @@ import { } from '@/app/workspace/[workspaceId]/templates/components/template-card' import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' import { getAllBlocks } from '@/blocks' +import { useSearchNavigation } from './hooks/use-search-navigation' -interface SearchModalProps { +export interface SearchModalProps { open: boolean onOpenChange: (open: boolean) => void templates?: TemplateData[] @@ -24,7 +25,7 @@ interface SearchModalProps { isOnWorkflowPage?: boolean } -interface TemplateData { +export interface TemplateData { id: string title: string description: string @@ -39,21 +40,21 @@ interface TemplateData { isStarred?: boolean } -interface WorkflowItem { +export interface WorkflowItem { id: string name: string href: string isCurrent?: boolean } -interface WorkspaceItem { +export interface WorkspaceItem { id: string name: string href: string isCurrent?: boolean } -interface BlockItem { +export interface BlockItem { id: string name: string icon: React.ComponentType @@ -61,7 +62,7 @@ interface BlockItem { type: string } -interface ToolItem { +export interface ToolItem { id: string name: string icon: React.ComponentType @@ -69,7 +70,7 @@ interface ToolItem { type: string } -interface PageItem { +export interface PageItem { id: string name: string icon: React.ComponentType @@ -77,7 +78,7 @@ interface PageItem { shortcut?: string } -interface DocItem { +export interface DocItem { id: string name: string icon: React.ComponentType @@ -85,13 +86,12 @@ interface DocItem { type: 'main' | 'block' | 'tool' } -// Navigation types for the new system -interface NavigationPosition { +export interface NavigationPosition { sectionIndex: number itemIndex: number } -interface NavigationSection { +export interface NavigationSection { id: string name: string type: 'grid' | 'list' @@ -99,244 +99,6 @@ interface NavigationSection { gridCols?: number // How many columns per row for grid sections } -// Custom hook for navigation logic -function useSearchNavigation(sections: NavigationSection[], open: boolean) { - const [position, setPosition] = useState({ sectionIndex: 0, itemIndex: 0 }) - const scrollRefs = useRef>(new Map()) - const lastItemIndex = useRef>(new Map()) // Store last item index for each section - - // Reset position when modal opens or sections change - useEffect(() => { - if (open) { - setPosition({ sectionIndex: 0, itemIndex: 0 }) - } - }, [open, sections]) - - // Get current selected item - const getCurrentItem = useCallback(() => { - const section = sections[position.sectionIndex] - if (!section || position.itemIndex >= section.items.length) return null - - return { - section, - item: section.items[position.itemIndex], - position, - } - }, [sections, position]) - - // Navigate with keyboard - const navigate = useCallback( - (direction: 'up' | 'down' | 'left' | 'right') => { - setPosition((prev) => { - const section = sections[prev.sectionIndex] - if (!section) return prev - - // Debug logging - console.log( - 'Navigate:', - direction, - 'Current:', - prev, - 'Section:', - section.id, - 'Items:', - section.items.length - ) - - switch (direction) { - case 'down': - if (section.type === 'grid' && section.gridCols) { - // CSS Grid with grid-flow-col and 2 rows means items flow column-wise - // Index pattern: 0=R0C0, 1=R1C0, 2=R0C1, 3=R1C1, 4=R0C2, 5=R1C2... - const totalRows = section.id === 'templates' ? 1 : 2 - const currentCol = Math.floor(prev.itemIndex / totalRows) - const currentRow = prev.itemIndex % totalRows - - console.log('Grid down:', { - itemIndex: prev.itemIndex, - totalRows, - currentCol, - currentRow, - }) - - // Try to move down one row in the same column - if (currentRow < totalRows - 1) { - const nextIndex = currentCol * totalRows + (currentRow + 1) - if (nextIndex < section.items.length) { - return { ...prev, itemIndex: nextIndex } - } - } - } else if (section.type === 'list') { - // For list navigation, move to next item within the list - if (prev.itemIndex < section.items.length - 1) { - return { ...prev, itemIndex: prev.itemIndex + 1 } - } - } - // Move to next section - if (prev.sectionIndex < sections.length - 1) { - const nextSection = sections[prev.sectionIndex + 1] - - // Store current item index for this section - lastItemIndex.current.set(section.id, prev.itemIndex) - - // Check if we have a remembered position for the next section - const rememberedIndex = lastItemIndex.current.get(nextSection.id) - const targetIndex = - rememberedIndex !== undefined - ? Math.min(rememberedIndex, nextSection.items.length - 1) - : 0 - - console.log('Section transition down:', { - from: section.id, - to: nextSection.id, - storedIndex: prev.itemIndex, - rememberedIndex, - targetIndex, - }) - - return { sectionIndex: prev.sectionIndex + 1, itemIndex: targetIndex } - } - return prev - - case 'up': - if (section.type === 'grid' && section.gridCols) { - // CSS Grid with grid-flow-col and 2 rows means items flow column-wise - const totalRows = section.id === 'templates' ? 1 : 2 - const currentCol = Math.floor(prev.itemIndex / totalRows) - const currentRow = prev.itemIndex % totalRows - - console.log('Grid up:', { - itemIndex: prev.itemIndex, - totalRows, - currentCol, - currentRow, - }) - - // Try to move up one row in the same column - if (currentRow > 0) { - const prevIndex = currentCol * totalRows + (currentRow - 1) - return { ...prev, itemIndex: prevIndex } - } - } else if (section.type === 'list') { - // For list navigation, move to previous item within the list - if (prev.itemIndex > 0) { - return { ...prev, itemIndex: prev.itemIndex - 1 } - } - } - // Move to previous section - if (prev.sectionIndex > 0) { - const prevSection = sections[prev.sectionIndex - 1] - - // Store current item index for this section - lastItemIndex.current.set(section.id, prev.itemIndex) - - // Check if we have a remembered position for the previous section - const rememberedIndex = lastItemIndex.current.get(prevSection.id) - const targetIndex = - rememberedIndex !== undefined - ? Math.min(rememberedIndex, prevSection.items.length - 1) - : prevSection.items.length - 1 // Default to last item - - console.log('Section transition up:', { - from: section.id, - to: prevSection.id, - storedIndex: prev.itemIndex, - rememberedIndex, - targetIndex, - }) - - return { sectionIndex: prev.sectionIndex - 1, itemIndex: targetIndex } - } - return prev - - case 'right': - if (section.type === 'grid' && section.gridCols) { - // CSS Grid with grid-flow-col: move right means next column, same row - const totalRows = section.id === 'templates' ? 1 : 2 - const currentCol = Math.floor(prev.itemIndex / totalRows) - const currentRow = prev.itemIndex % totalRows - const totalCols = Math.ceil(section.items.length / totalRows) - - // Can we move right to the next column? - if (currentCol < totalCols - 1) { - const nextIndex = (currentCol + 1) * totalRows + currentRow - if (nextIndex < section.items.length) { - return { ...prev, itemIndex: nextIndex } - } - } - } else if (section.type === 'list') { - // List navigation - just move to next item - if (prev.itemIndex < section.items.length - 1) { - return { ...prev, itemIndex: prev.itemIndex + 1 } - } - } - return prev - - case 'left': - if (section.type === 'grid' && section.gridCols) { - // CSS Grid with grid-flow-col: move left means previous column, same row - const totalRows = section.id === 'templates' ? 1 : 2 - const currentCol = Math.floor(prev.itemIndex / totalRows) - const currentRow = prev.itemIndex % totalRows - - // Can we move left to the previous column? - if (currentCol > 0) { - const prevIndex = (currentCol - 1) * totalRows + currentRow - return { ...prev, itemIndex: prevIndex } - } - } else if (section.type === 'list') { - // List navigation - move to previous item - if (prev.itemIndex > 0) { - return { ...prev, itemIndex: prev.itemIndex - 1 } - } - } - return prev - - default: - return prev - } - }) - }, - [sections] - ) - - // Scroll item into view - const scrollIntoView = useCallback(() => { - const current = getCurrentItem() - if (!current) return - - const container = scrollRefs.current.get(current.section.id) - if (!container) return - - const itemSelector = `[data-nav-item="${current.section.id}-${current.position.itemIndex}"]` - const element = container.querySelector(itemSelector) as HTMLElement - if (!element) return - - // Use modern scrollIntoView with proper options - element.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', // This ensures horizontal centering - }) - }, [getCurrentItem]) - - // Trigger scroll when position changes - useEffect(() => { - if (open) { - // Small delay to ensure DOM is updated - const timer = setTimeout(scrollIntoView, 10) - return () => clearTimeout(timer) - } - }, [position, open, scrollIntoView]) - - return { - position, - navigate, - getCurrentItem, - scrollRefs, - } -} - export function SearchModal({ open, onOpenChange, @@ -351,15 +113,12 @@ export function SearchModal({ const router = useRouter() const workspaceId = params.workspaceId as string - // Local state for templates to handle star changes const [localTemplates, setLocalTemplates] = useState(templates) - // Update local templates when props change useEffect(() => { setLocalTemplates(templates) }, [templates]) - // Get all available blocks - only when on workflow page const blocks = useMemo(() => { if (!isOnWorkflowPage) return [] @@ -383,7 +142,6 @@ export function SearchModal({ .sort((a, b) => a.name.localeCompare(b.name)) }, [isOnWorkflowPage]) - // Get all available tools - only when on workflow page const tools = useMemo(() => { if (!isOnWorkflowPage) return [] @@ -402,7 +160,6 @@ export function SearchModal({ .sort((a, b) => a.name.localeCompare(b.name)) }, [isOnWorkflowPage]) - // Define pages const pages = useMemo( (): PageItem[] => [ { @@ -435,12 +192,10 @@ export function SearchModal({ [workspaceId] ) - // Define docs const docs = useMemo((): DocItem[] => { const allBlocks = getAllBlocks() const docsItems: DocItem[] = [] - // Add individual block/tool docs allBlocks.forEach((block) => { if (block.docsLink) { docsItems.push({ @@ -456,7 +211,6 @@ export function SearchModal({ return docsItems.sort((a, b) => a.name.localeCompare(b.name)) }, []) - // Filter all items based on search query const filteredBlocks = useMemo(() => { if (!searchQuery.trim()) return blocks const query = searchQuery.toLowerCase() @@ -505,11 +259,9 @@ export function SearchModal({ return docs.filter((doc) => doc.name.toLowerCase().includes(query)) }, [docs, searchQuery]) - // Create navigation sections const navigationSections = useMemo((): NavigationSection[] => { const sections: NavigationSection[] = [] - // Add blocks section if (filteredBlocks.length > 0) { sections.push({ id: 'blocks', @@ -520,7 +272,6 @@ export function SearchModal({ }) } - // Add tools section if (filteredTools.length > 0) { sections.push({ id: 'tools', @@ -531,7 +282,6 @@ export function SearchModal({ }) } - // Add templates section if (filteredTemplates.length > 0) { sections.push({ id: 'templates', @@ -542,7 +292,6 @@ export function SearchModal({ }) } - // Add list sections const listItems = [ ...filteredWorkspaces.map((item) => ({ type: 'workspace', data: item })), ...filteredWorkflows.map((item) => ({ type: 'workflow', data: item })), @@ -570,17 +319,14 @@ export function SearchModal({ filteredDocs, ]) - // Use the navigation hook const { navigate, getCurrentItem, scrollRefs } = useSearchNavigation(navigationSections, open) - // Clear search when modal closes useEffect(() => { if (!open) { setSearchQuery('') } }, [open]) - // Handle block/tool click const handleBlockClick = useCallback( (blockType: string) => { const event = new CustomEvent('add-block-from-toolbar', { @@ -592,7 +338,6 @@ export function SearchModal({ [onOpenChange] ) - // Handle page navigation const handlePageClick = useCallback( (href: string) => { if (href.startsWith('http')) { @@ -605,7 +350,6 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle workflow/workspace navigation const handleNavigationClick = useCallback( (href: string) => { router.push(href) @@ -614,7 +358,6 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle docs navigation const handleDocsClick = useCallback( (href: string) => { if (href.startsWith('http')) { @@ -627,7 +370,6 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle item selection const handleItemSelection = useCallback(() => { const current = getCurrentItem() if (!current) return @@ -671,7 +413,6 @@ export function SearchModal({ onOpenChange, ]) - // Handle keyboard navigation useEffect(() => { if (!open) return @@ -707,7 +448,6 @@ export function SearchModal({ return () => document.removeEventListener('keydown', handleKeyDown) }, [open, navigate, handleItemSelection, onOpenChange]) - // Handle template star changes const handleStarChange = useCallback( (templateId: string, isStarred: boolean, newStarCount: number) => { setLocalTemplates((prevTemplates) => @@ -719,7 +459,6 @@ export function SearchModal({ [] ) - // Helper to check if item is selected const isItemSelected = useCallback( (sectionId: string, itemIndex: number) => { const current = getCurrentItem() @@ -728,7 +467,6 @@ export function SearchModal({ [getCurrentItem] ) - // Render skeleton cards for loading state const renderSkeletonCards = () => { return Array.from({ length: 8 }).map((_, index) => (