Skip to content

Commit 4d8765e

Browse files
committed
UI improvements
1 parent 4310f20 commit 4d8765e

File tree

5 files changed

+310
-97
lines changed

5 files changed

+310
-97
lines changed

webview-ui/src/components/settings/SettingsSearch.tsx

Lines changed: 96 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,121 @@
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"
73

84
import { useSettingsSearch, SearchResult, SearchableSettingData } from "./useSettingsSearch"
95
import { SectionName } from "./SettingsView"
6+
import { SettingsSearchInput } from "./SettingsSearchInput"
7+
import { SettingsSearchResults } from "./SettingsSearchResults"
108

119
interface SettingsSearchProps {
1210
index: SearchableSettingData[]
1311
onNavigate: (section: SectionName, settingId: string) => void
12+
sections: { id: SectionName; icon: LucideIcon }[]
1413
}
1514

16-
export function SettingsSearch({ index, onNavigate }: SettingsSearchProps) {
17-
const { t } = useAppTranslation()
15+
export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchProps) {
1816
const inputRef = useRef<HTMLInputElement>(null)
19-
const dropdownRef = useRef<HTMLDivElement>(null)
2017
const { searchQuery, setSearchQuery, results, isOpen, setIsOpen, clearSearch } = useSettingsSearch({ index })
21-
const [selectedIndex, setSelectedIndex] = useState(0)
18+
const [highlightedResultId, setHighlightedResultId] = useState<string | undefined>(undefined)
2219

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)
3524
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+
)
3932

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+
)
4444

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
4948

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") {
5871
setIsOpen(false)
72+
setHighlightedResultId(undefined)
73+
inputRef.current?.blur()
74+
return
5975
}
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
6085
}
61-
document.addEventListener("mousedown", handleClickOutside)
62-
return () => document.removeEventListener("mousedown", handleClickOutside)
63-
}, [setIsOpen])
6486

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])
9191

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
11195

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+
/>
117119
</div>
118120
)}
119121
</div>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type RefObject } from "react"
2+
import { Search, X } from "lucide-react"
3+
4+
import { cn } from "@/lib/utils"
5+
import { useAppTranslation } from "@/i18n/TranslationContext"
6+
import { Input } from "@/components/ui"
7+
8+
export interface SettingsSearchInputProps {
9+
value: string
10+
onChange: (value: string) => void
11+
onFocus?: () => void
12+
onBlur?: () => void
13+
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
14+
inputRef?: RefObject<HTMLInputElement>
15+
}
16+
17+
export function SettingsSearchInput({
18+
value,
19+
onChange,
20+
onFocus,
21+
onBlur,
22+
onKeyDown,
23+
inputRef,
24+
}: SettingsSearchInputProps) {
25+
const { t } = useAppTranslation()
26+
27+
return (
28+
<div className="relative flex items-center ml-2">
29+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-vscode-descriptionForeground pointer-events-none z-10" />
30+
<Input
31+
ref={inputRef}
32+
data-testid="settings-search-input"
33+
type="text"
34+
value={value}
35+
onChange={(e) => onChange(e.target.value)}
36+
onFocus={onFocus}
37+
onBlur={onBlur}
38+
onKeyDown={onKeyDown}
39+
placeholder={t("settings:search.placeholder")}
40+
className={cn(
41+
"pl-8 pr-2.5 h-7 text-sm rounded-full border border-vscode-input-border bg-vscode-input-background focus:border-vscode-focusBorder",
42+
value && "pr-7",
43+
)}
44+
/>
45+
{value && (
46+
<button
47+
type="button"
48+
onClick={() => onChange("")}
49+
className="absolute cursor-pointer right-2 top-1/2 -translate-y-1/2 text-vscode-descriptionForeground hover:text-vscode-foreground focus:outline-none"
50+
aria-label="Clear search">
51+
<X className="size-3.5" />
52+
</button>
53+
)}
54+
</div>
55+
)
56+
}

0 commit comments

Comments
 (0)