|
1 | | -import { useRef, useEffect, useState } from "react" |
2 | | -import { Search, X } from "lucide-react" |
3 | | - |
4 | | -import { Input } from "@/components/ui" |
5 | | -import { useAppTranslation } from "@/i18n/TranslationContext" |
6 | | -import { cn } from "@/lib/utils" |
| 1 | +import { useRef, useEffect, useState, useCallback } from "react" |
| 2 | +import type { LucideIcon } from "lucide-react" |
7 | 3 |
|
8 | 4 | import { useSettingsSearch, SearchResult, SearchableSettingData } from "./useSettingsSearch" |
9 | 5 | import { SectionName } from "./SettingsView" |
| 6 | +import { SettingsSearchInput } from "./SettingsSearchInput" |
| 7 | +import { SettingsSearchResults } from "./SettingsSearchResults" |
10 | 8 |
|
11 | 9 | interface SettingsSearchProps { |
12 | 10 | index: SearchableSettingData[] |
13 | 11 | onNavigate: (section: SectionName, settingId: string) => void |
| 12 | + sections: { id: SectionName; icon: LucideIcon }[] |
14 | 13 | } |
15 | 14 |
|
16 | | -export function SettingsSearch({ index, onNavigate }: SettingsSearchProps) { |
17 | | - const { t } = useAppTranslation() |
| 15 | +export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchProps) { |
18 | 16 | const inputRef = useRef<HTMLInputElement>(null) |
19 | | - const dropdownRef = useRef<HTMLDivElement>(null) |
20 | 17 | const { searchQuery, setSearchQuery, results, isOpen, setIsOpen, clearSearch } = useSettingsSearch({ index }) |
21 | | - const [selectedIndex, setSelectedIndex] = useState(0) |
| 18 | + const [highlightedResultId, setHighlightedResultId] = useState<string | undefined>(undefined) |
22 | 19 |
|
23 | | - // Handle keyboard navigation |
24 | | - const handleKeyDown = (e: React.KeyboardEvent) => { |
25 | | - if (e.key === "ArrowDown") { |
26 | | - e.preventDefault() |
27 | | - setSelectedIndex((i) => Math.min(i + 1, results.length - 1)) |
28 | | - } else if (e.key === "ArrowUp") { |
29 | | - e.preventDefault() |
30 | | - setSelectedIndex((i) => Math.max(i - 1, 0)) |
31 | | - } else if (e.key === "Enter" && results[selectedIndex]) { |
32 | | - e.preventDefault() |
33 | | - handleSelect(results[selectedIndex]) |
34 | | - } else if (e.key === "Escape") { |
| 20 | + // Handle selection of a search result |
| 21 | + const handleSelectResult = useCallback( |
| 22 | + (result: SearchResult) => { |
| 23 | + onNavigate(result.section, result.settingId) |
35 | 24 | clearSearch() |
36 | | - inputRef.current?.blur() |
37 | | - } |
38 | | - } |
| 25 | + setHighlightedResultId(undefined) |
| 26 | + // Keep focus in the input so dropdown remains open for follow-up search |
| 27 | + setIsOpen(true) |
| 28 | + requestAnimationFrame(() => inputRef.current?.focus()) |
| 29 | + }, |
| 30 | + [onNavigate, clearSearch, setIsOpen], |
| 31 | + ) |
39 | 32 |
|
40 | | - const handleSelect = (result: SearchResult) => { |
41 | | - onNavigate(result.section, result.settingId) |
42 | | - clearSearch() |
43 | | - } |
| 33 | + // Keyboard navigation inside search results |
| 34 | + const moveHighlight = useCallback( |
| 35 | + (direction: 1 | -1) => { |
| 36 | + if (!results.length) return |
| 37 | + const flatIds = results.map((r) => r.settingId) |
| 38 | + const currentIndex = highlightedResultId ? flatIds.indexOf(highlightedResultId) : -1 |
| 39 | + const nextIndex = (currentIndex + direction + flatIds.length) % flatIds.length |
| 40 | + setHighlightedResultId(flatIds[nextIndex]) |
| 41 | + }, |
| 42 | + [highlightedResultId, results], |
| 43 | + ) |
44 | 44 |
|
45 | | - // Reset selected index when results change |
46 | | - useEffect(() => { |
47 | | - setSelectedIndex(0) |
48 | | - }, [results]) |
| 45 | + const handleSearchKeyDown = useCallback( |
| 46 | + (event: React.KeyboardEvent<HTMLInputElement>) => { |
| 47 | + if (!results.length) return |
49 | 48 |
|
50 | | - // Close dropdown when clicking outside |
51 | | - useEffect(() => { |
52 | | - const handleClickOutside = (e: MouseEvent) => { |
53 | | - if ( |
54 | | - dropdownRef.current && |
55 | | - !dropdownRef.current.contains(e.target as Node) && |
56 | | - !inputRef.current?.contains(e.target as Node) |
57 | | - ) { |
| 49 | + if (event.key === "ArrowDown") { |
| 50 | + event.preventDefault() |
| 51 | + moveHighlight(1) |
| 52 | + return |
| 53 | + } |
| 54 | + |
| 55 | + if (event.key === "ArrowUp") { |
| 56 | + event.preventDefault() |
| 57 | + moveHighlight(-1) |
| 58 | + return |
| 59 | + } |
| 60 | + |
| 61 | + if (event.key === "Enter" && highlightedResultId) { |
| 62 | + event.preventDefault() |
| 63 | + const selected = results.find((r) => r.settingId === highlightedResultId) |
| 64 | + if (selected) { |
| 65 | + handleSelectResult(selected) |
| 66 | + } |
| 67 | + return |
| 68 | + } |
| 69 | + |
| 70 | + if (event.key === "Escape") { |
58 | 71 | setIsOpen(false) |
| 72 | + setHighlightedResultId(undefined) |
| 73 | + inputRef.current?.blur() |
| 74 | + return |
59 | 75 | } |
| 76 | + }, |
| 77 | + [handleSelectResult, highlightedResultId, moveHighlight, results, setIsOpen], |
| 78 | + ) |
| 79 | + |
| 80 | + // Reset highlight based on focus and available results |
| 81 | + useEffect(() => { |
| 82 | + if (!isOpen || !results.length) { |
| 83 | + setHighlightedResultId(undefined) |
| 84 | + return |
60 | 85 | } |
61 | | - document.addEventListener("mousedown", handleClickOutside) |
62 | | - return () => document.removeEventListener("mousedown", handleClickOutside) |
63 | | - }, [setIsOpen]) |
64 | 86 |
|
65 | | - return ( |
66 | | - <div className="relative"> |
67 | | - <div className="relative"> |
68 | | - <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-vscode-descriptionForeground" /> |
69 | | - <Input |
70 | | - ref={inputRef} |
71 | | - type="text" |
72 | | - placeholder={t("settings:search.placeholder")} |
73 | | - value={searchQuery} |
74 | | - onChange={(e) => { |
75 | | - setSearchQuery(e.target.value) |
76 | | - setIsOpen(true) |
77 | | - }} |
78 | | - onFocus={() => searchQuery && setIsOpen(true)} |
79 | | - onKeyDown={handleKeyDown} |
80 | | - className="pl-8 pr-8 h-8 w-48" |
81 | | - /> |
82 | | - {searchQuery && ( |
83 | | - <button |
84 | | - onClick={clearSearch} |
85 | | - className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5" |
86 | | - type="button"> |
87 | | - <X className="w-4 h-4 text-vscode-descriptionForeground hover:text-vscode-foreground" /> |
88 | | - </button> |
89 | | - )} |
90 | | - </div> |
| 87 | + setHighlightedResultId((current) => |
| 88 | + current && results.some((r) => r.settingId === current) ? current : results[0]?.settingId, |
| 89 | + ) |
| 90 | + }, [isOpen, results]) |
91 | 91 |
|
92 | | - {isOpen && results.length > 0 && ( |
93 | | - <div |
94 | | - ref={dropdownRef} |
95 | | - className="absolute top-full left-0 right-0 mt-1 z-50 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded-md shadow-lg max-h-64 overflow-y-auto"> |
96 | | - {results.map((result, index) => ( |
97 | | - <button |
98 | | - key={`${result.section}-${result.settingId}`} |
99 | | - onClick={() => handleSelect(result)} |
100 | | - className={cn( |
101 | | - "w-full px-3 py-2 text-left hover:bg-vscode-list-hoverBackground", |
102 | | - index === selectedIndex && "bg-vscode-list-activeSelectionBackground", |
103 | | - )} |
104 | | - type="button"> |
105 | | - <div className="text-sm font-medium text-vscode-foreground truncate">{result.label}</div> |
106 | | - <div className="text-xs text-vscode-descriptionForeground">→ {result.sectionLabel}</div> |
107 | | - </button> |
108 | | - ))} |
109 | | - </div> |
110 | | - )} |
| 92 | + // Ensure highlighted search result stays visible within dropdown |
| 93 | + useEffect(() => { |
| 94 | + if (!highlightedResultId || !isOpen) return |
111 | 95 |
|
112 | | - {isOpen && searchQuery && results.length === 0 && ( |
113 | | - <div |
114 | | - ref={dropdownRef} |
115 | | - className="absolute top-full left-0 right-0 mt-1 z-50 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded-md shadow-lg p-3 text-sm text-vscode-descriptionForeground"> |
116 | | - {t("settings:search.noResults")} |
| 96 | + const element = document.getElementById(`settings-search-result-${highlightedResultId}`) |
| 97 | + element?.scrollIntoView({ block: "nearest" }) |
| 98 | + }, [highlightedResultId, isOpen]) |
| 99 | + |
| 100 | + return ( |
| 101 | + <div className="relative justify-end"> |
| 102 | + <SettingsSearchInput |
| 103 | + value={searchQuery} |
| 104 | + onChange={setSearchQuery} |
| 105 | + onFocus={() => setIsOpen(true)} |
| 106 | + onBlur={() => setTimeout(() => setIsOpen(false), 200)} |
| 107 | + onKeyDown={handleSearchKeyDown} |
| 108 | + inputRef={inputRef} |
| 109 | + /> |
| 110 | + {searchQuery && isOpen && ( |
| 111 | + <div className="absolute top-full min-w-[300px] left-0 mt-2 border border-vscode-dropdown-border bg-vscode-dropdown-background rounded-2xl overflow-hidden shadow-xl z-50"> |
| 112 | + <SettingsSearchResults |
| 113 | + results={results} |
| 114 | + query={searchQuery} |
| 115 | + onSelectResult={handleSelectResult} |
| 116 | + sections={sections} |
| 117 | + highlightedResultId={highlightedResultId} |
| 118 | + /> |
117 | 119 | </div> |
118 | 120 | )} |
119 | 121 | </div> |
|
0 commit comments