diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx index c2b1906069..c0b7165470 100644 --- a/webview-ui/src/components/settings/SettingsSearchInput.tsx +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -9,9 +9,10 @@ export interface SettingsSearchInputProps { onChange: (value: string) => void onFocus?: () => void onBlur?: () => void + onKeyDown?: React.KeyboardEventHandler } -export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: SettingsSearchInputProps) { +export function SettingsSearchInput({ value, onChange, onFocus, onBlur, onKeyDown }: SettingsSearchInputProps) { const { t } = useAppTranslation() return ( @@ -24,10 +25,11 @@ export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: Settin onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} + onKeyDown={onKeyDown} placeholder={t("settings:search.placeholder")} className={cn( - "pl-6 w-[0px] focus:pl-8 focus:min-w-[130px] focus:w-full active:w-full", - value && "pr-8", + "pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[130px] focus:w-full", + value && "pr-4 min-w-[150px]", )} /> {value && ( diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx index c287335b55..66e041787f 100644 --- a/webview-ui/src/components/settings/SettingsSearchResults.tsx +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -4,12 +4,14 @@ import type { LucideIcon } from "lucide-react" import { useAppTranslation } from "@/i18n/TranslationContext" import type { SearchResult } from "@/hooks/useSettingsSearch" import type { SectionName } from "@/utils/parseSettingsI18nKeys" +import { cn } from "@/lib/utils" export interface SettingsSearchResultsProps { results: SearchResult[] query: string onSelectResult: (result: SearchResult) => void sections: { id: SectionName; icon: LucideIcon }[] + highlightedResultId?: string } interface HighlightMatchProps { @@ -44,7 +46,13 @@ function HighlightMatch({ text, query }: HighlightMatchProps) { ) } -export function SettingsSearchResults({ results, query, onSelectResult, sections }: SettingsSearchResultsProps) { +export function SettingsSearchResults({ + results, + query, + onSelectResult, + sections, + highlightedResultId, +}: SettingsSearchResultsProps) { const { t } = useAppTranslation() // Group results by tab @@ -70,42 +78,55 @@ export function SettingsSearchResults({ results, query, onSelectResult, sections // If no results, show a message if (results.length === 0) { return ( -
+
{t("settings:search.noResults", { query })}
) } return ( -
+
{Object.entries(groupedResults).map(([tab, tabResults]) => { const Icon = sectionIconMap.get(tab as SectionName) return (
{/* Tab header */} -
+
{Icon && } {t(`settings:sections.${tab}`)}
{/* Result items */} - {tabResults.map((result) => ( - - ))} + {result.translatedDescription && ( +
+ +
+ )} + + ) + })}
) })} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c7f9e7ad7a..1f9b7d32b4 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -136,6 +136,7 @@ const SettingsView = forwardRef(({ onDone, t ) const [searchQuery, setSearchQuery] = useState("") const [isSearchFocused, setIsSearchFocused] = useState(false) + const [highlightedResultId, setHighlightedResultId] = useState(undefined) const scrollPositions = useRef>( Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record, @@ -590,6 +591,7 @@ const SettingsView = forwardRef(({ onDone, t (result: SearchResult) => { setSearchQuery("") setIsSearchFocused(false) + setHighlightedResultId(undefined) handleTabChange(result.tab) // Small delay to allow tab switch and render setTimeout(() => scrollToSetting(result.id), 150) @@ -597,10 +599,76 @@ const SettingsView = forwardRef(({ onDone, t [handleTabChange, scrollToSetting], ) + // Keyboard navigation inside search results + const moveHighlight = useCallback( + (direction: 1 | -1) => { + if (!searchResults.length) return + const flatIds = searchResults.map((r) => r.id) + const currentIndex = highlightedResultId ? flatIds.indexOf(highlightedResultId) : -1 + const nextIndex = (currentIndex + direction + flatIds.length) % flatIds.length + setHighlightedResultId(flatIds[nextIndex]) + }, + [highlightedResultId, searchResults], + ) + + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!searchResults.length) return + + if (event.key === "ArrowDown") { + event.preventDefault() + moveHighlight(1) + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + moveHighlight(-1) + return + } + + if (event.key === "Enter" && highlightedResultId) { + event.preventDefault() + const selected = searchResults.find((r) => r.id === highlightedResultId) + if (selected) { + handleSelectResult(selected) + } + return + } + + if (event.key === "Escape") { + setIsSearchFocused(false) + setHighlightedResultId(undefined) + return + } + }, + [handleSelectResult, highlightedResultId, moveHighlight, searchResults], + ) + + // Reset highlight based on focus and available results + useEffect(() => { + if (!isSearchFocused || !searchResults.length) { + setHighlightedResultId(undefined) + return + } + + setHighlightedResultId((current) => + current && searchResults.some((r) => r.id === current) ? current : searchResults[0]?.id, + ) + }, [isSearchFocused, searchResults]) + + // Ensure highlighted search result stays visible within dropdown + useEffect(() => { + if (!highlightedResultId || !isSearchFocused) return + + const element = document.getElementById(`settings-search-result-${highlightedResultId}`) + element?.scrollIntoView({ block: "nearest" }) + }, [highlightedResultId, isSearchFocused]) + return ( -
+
+ ), + StandardTooltip: ({ children }: any) => <>{children}, + Input: ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }: any) => ( + + ), + AlertDialog: ({ children }: any) =>
{children}
, + AlertDialogContent: ({ children }: any) =>
{children}
, + AlertDialogTitle: ({ children }: any) =>
{children}
, + AlertDialogDescription: ({ children }: any) =>
{children}
, + AlertDialogCancel: ({ children, onClick }: any) => , + AlertDialogAction: ({ children, onClick }: any) => , + AlertDialogHeader: ({ children }: any) =>
{children}
, + AlertDialogFooter: ({ children }: any) =>
{children}
, + Tooltip: ({ children }: any) => <>{children}, + TooltipContent: ({ children }: any) =>
{children}
, + TooltipProvider: ({ children }: any) => <>{children}, + TooltipTrigger: ({ children, onClick }: any) =>
{children}
, +})) + +vi.mock("../../common/Tab", () => ({ + Tab: ({ children }: any) =>
{children}
, + TabHeader: ({ children }: any) =>
{children}
, + TabContent: ({ children }: any) =>
{children}
, + TabList: ({ children }: any) =>
{children}
, + TabTrigger: ({ children, onClick }: any) => , +})) + +vi.mock("../ApiConfigManager", () => ({ + __esModule: true, + default: () =>
ApiConfigManager
, +})) + +vi.mock("../ApiOptions", () => ({ + __esModule: true, + default: () =>
ApiOptions
, +})) + +// Mock all settings subsections to inert components +vi.mock("../AutoApproveSettings", () => ({ AutoApproveSettings: () =>
AutoApproveSettings
})) +vi.mock("../BrowserSettings", () => ({ BrowserSettings: () =>
BrowserSettings
})) +vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: () =>
CheckpointSettings
})) +vi.mock("../NotificationSettings", () => ({ NotificationSettings: () =>
NotificationSettings
})) +vi.mock("../ContextManagementSettings", () => ({ + ContextManagementSettings: () =>
ContextManagementSettings
, +})) +vi.mock("../TerminalSettings", () => ({ TerminalSettings: () =>
TerminalSettings
})) +vi.mock("../ExperimentalSettings", () => ({ ExperimentalSettings: () =>
ExperimentalSettings
})) +vi.mock("../LanguageSettings", () => ({ LanguageSettings: () =>
LanguageSettings
})) +vi.mock("../About", () => ({ About: () =>
About
})) +vi.mock("../PromptsSettings", () => ({ __esModule: true, default: () =>
PromptsSettings
})) +vi.mock("../SlashCommandsSettings", () => ({ SlashCommandsSettings: () =>
SlashCommandsSettings
})) +vi.mock("../UISettings", () => ({ UISettings: () =>
UISettings
})) + +describe("SettingsView search interactions", () => { + beforeEach(() => { + ;(global as any).ResizeObserver = (global as any).ResizeObserver || ResizeObserverPolyfill + mockUseExtensionState.mockReturnValue(defaultExtensionState) + mockUseSettingsSearch.mockImplementation(() => mockSearchResults) + }) + + it("allows clicking a search result without closing before selection", async () => { + render() + + const input = screen.getByTestId("settings-search-input") as HTMLInputElement + fireEvent.focus(input) + fireEvent.change(input, { target: { value: "browser" } }) + + const listbox = await screen.findByRole("listbox") + expect(listbox).toBeInTheDocument() + + const options = screen.getAllByRole("option") + fireEvent.mouseDown(options[0]) + fireEvent.click(options[0]) + + expect(input.value).toBe("") + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + + it("supports keyboard navigation and enter selection from search input", async () => { + render() + + const input = screen.getByTestId("settings-search-input") as HTMLInputElement + fireEvent.focus(input) + fireEvent.change(input, { target: { value: "browser" } }) + + await screen.findByRole("listbox") + let options = screen.getAllByRole("option") + expect(options[0]).toHaveAttribute("aria-selected", "true") + + fireEvent.keyDown(input, { key: "ArrowDown" }) + options = screen.getAllByRole("option") + expect(options[1]).toHaveAttribute("aria-selected", "true") + + fireEvent.keyDown(input, { key: "Enter" }) + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + expect(input.value).toBe("") + }) +})