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 11812463695..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,6 +86,19 @@ interface DocItem { type: 'main' | 'block' | 'tool' } +export interface NavigationPosition { + sectionIndex: number + itemIndex: number +} + +export interface NavigationSection { + id: string + name: string + type: 'grid' | 'list' + items: any[] + gridCols?: number // How many columns per row for grid sections +} + export function SearchModal({ open, onOpenChange, @@ -95,51 +109,16 @@ 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 - // Local state for templates to handle star changes const [localTemplates, setLocalTemplates] = useState(templates) - // Update local templates when props change useEffect(() => { 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 [] @@ -163,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 [] @@ -182,7 +160,6 @@ export function SearchModal({ .sort((a, b) => a.name.localeCompare(b.name)) }, [isOnWorkflowPage]) - // Define pages const pages = useMemo( (): PageItem[] => [ { @@ -215,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({ @@ -236,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() @@ -285,71 +259,78 @@ 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' }) - }) + const navigationSections = useMemo((): NavigationSection[] => { + const sections: NavigationSection[] = [] - // Add docs - filteredDocs.forEach((doc) => { - items.push({ type: 'doc', data: doc, section: 'Docs' }) - }) - - return items - }, [filteredWorkspaces, filteredWorkflows, filteredPages, filteredDocs]) + 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]) + 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) - } + 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) + 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]) - // Clear search when modal closes + return sections + }, [ + filteredBlocks, + filteredTools, + filteredTemplates, + filteredWorkspaces, + filteredWorkflows, + filteredPages, + filteredDocs, + ]) + + const { navigate, getCurrentItem, scrollRefs } = useSearchNavigation(navigationSections, open) + useEffect(() => { if (!open) { setSearchQuery('') } }, [open]) - // Handle block/tool click (same as toolbar interaction) 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) @@ -357,10 +338,8 @@ export function SearchModal({ [onOpenChange] ) - // 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 +350,6 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle workflow/workspace navigation (same as page navigation) const handleNavigationClick = useCallback( (href: string) => { router.push(href) @@ -380,10 +358,8 @@ export function SearchModal({ [router, onOpenChange] ) - // 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 +370,17 @@ 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`) - } - } - } + const handleItemSelection = useCallback(() => { + const current = getCurrentItem() + if (!current) return - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [open, handlePageClick, workspaceId]) + const { section, item } = current - // 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 - ) - ) - }, - [] - ) - - // 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,11 +403,16 @@ export function SearchModal({ handleDocsClick(item.data.href) break } - }, - [handleNavigationClick, handlePageClick, handleDocsClick, onOpenChange] - ) + } + }, [ + getCurrentItem, + handleBlockClick, + handleNavigationClick, + handlePageClick, + handleDocsClick, + onOpenChange, + ]) - // Handle keyboard navigation useEffect(() => { if (!open) return @@ -494,18 +420,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,32 +446,27 @@ 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 + 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]) + 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 = () => { return Array.from({ length: 8 }).map((_, index) => (
@@ -560,6 +486,7 @@ export function SearchModal({ Search + {/* Header with search input */}
@@ -584,61 +511,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 +555,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 +600,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 +636,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 ( + + ) + })} +
+
+ )}
)}