Skip to content

Commit d044be4

Browse files
Merge pull request #16 from jamesmontemagno:copilot/add-extension-search-box
Add VS Code Extension Marketplace search with collapsible dropdown and sort options
2 parents e3af7e1 + 5cd8b6f commit d044be4

7 files changed

Lines changed: 829 additions & 20 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* Search Dropdown Component CSS Module */
2+
3+
.searchDropdown {
4+
position: relative;
5+
width: 100%;
6+
}
7+
8+
.searchResults {
9+
position: absolute;
10+
top: calc(100% + 0.5rem);
11+
left: 0;
12+
right: 0;
13+
background: var(--bg-white);
14+
border: 1px solid var(--border-color);
15+
border-radius: 12px;
16+
box-shadow: 0 12px 32px var(--shadow-color);
17+
max-height: 400px;
18+
overflow-y: auto;
19+
z-index: 1000;
20+
animation: slideDown 0.2s ease-out;
21+
}
22+
23+
@keyframes slideDown {
24+
from {
25+
opacity: 0;
26+
transform: translateY(-8px);
27+
}
28+
to {
29+
opacity: 1;
30+
transform: translateY(0);
31+
}
32+
}
33+
34+
.searchResultsList {
35+
list-style: none;
36+
margin: 0;
37+
padding: 0.5rem;
38+
}
39+
40+
.searchResultItem {
41+
padding: 0.875rem 1rem;
42+
cursor: pointer;
43+
border-radius: 8px;
44+
transition: all 0.15s ease;
45+
border: 1px solid transparent;
46+
}
47+
48+
.searchResultItem:hover,
49+
.searchResultItem:focus {
50+
background: var(--bg-light);
51+
border-color: var(--border-color);
52+
outline: none;
53+
}
54+
55+
.searchResultItem:active {
56+
background: var(--primary-color);
57+
color: white;
58+
}
59+
60+
.resultHeader {
61+
display: flex;
62+
align-items: center;
63+
justify-content: space-between;
64+
gap: 0.75rem;
65+
margin-bottom: 0.375rem;
66+
}
67+
68+
.resultTitle {
69+
font-size: 0.95rem;
70+
font-weight: 600;
71+
color: var(--text-color);
72+
margin: 0;
73+
display: flex;
74+
align-items: center;
75+
gap: 0.5rem;
76+
}
77+
78+
.extensionId {
79+
font-size: 0.8rem;
80+
color: var(--text-light);
81+
font-weight: 400;
82+
font-family: 'Monaco', 'Courier New', monospace;
83+
}
84+
85+
.resultStats {
86+
display: flex;
87+
align-items: center;
88+
gap: 0.5rem;
89+
font-size: 0.75rem;
90+
color: var(--text-lighter);
91+
white-space: nowrap;
92+
}
93+
94+
.installCount {
95+
display: flex;
96+
align-items: center;
97+
gap: 0.25rem;
98+
}
99+
100+
.resultDescription {
101+
font-size: 0.85rem;
102+
color: var(--text-light);
103+
margin: 0;
104+
line-height: 1.4;
105+
display: -webkit-box;
106+
-webkit-line-clamp: 2;
107+
-webkit-box-orient: vertical;
108+
overflow: hidden;
109+
}
110+
111+
.searchLoading {
112+
padding: 1.5rem;
113+
text-align: center;
114+
color: var(--text-light);
115+
display: flex;
116+
align-items: center;
117+
justify-content: center;
118+
gap: 0.75rem;
119+
}
120+
121+
.spinner {
122+
display: inline-block;
123+
width: 16px;
124+
height: 16px;
125+
border: 2px solid var(--border-color);
126+
border-top-color: var(--primary-color);
127+
border-radius: 50%;
128+
animation: spin 0.8s linear infinite;
129+
}
130+
131+
@keyframes spin {
132+
to {
133+
transform: rotate(360deg);
134+
}
135+
}
136+
137+
.searchError {
138+
padding: 1.25rem;
139+
text-align: center;
140+
color: var(--text-light);
141+
}
142+
143+
.searchEmpty {
144+
padding: 1.25rem;
145+
text-align: center;
146+
color: var(--text-light);
147+
}
148+
149+
.searchEmptyIcon {
150+
font-size: 1.5rem;
151+
margin-bottom: 0.5rem;
152+
opacity: 0.5;
153+
}
154+
155+
/* Keyboard navigation highlight */
156+
.searchResultItem[data-highlighted='true'] {
157+
background: var(--bg-light);
158+
border-color: var(--primary-color);
159+
}
160+
161+
@media (max-width: 768px) {
162+
.searchResults {
163+
max-height: 300px;
164+
}
165+
166+
.resultHeader {
167+
flex-direction: column;
168+
align-items: flex-start;
169+
}
170+
171+
.resultStats {
172+
width: 100%;
173+
}
174+
}

src/components/SearchDropdown.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useState, useEffect, useRef } from 'react'
2+
import type { KeyboardEvent } from 'react'
3+
import styles from './SearchDropdown.module.css'
4+
import { searchExtensions, formatInstallCount } from '../utils/marketplaceApi'
5+
import type { SearchResult, SortBy } from '../utils/marketplaceApi'
6+
7+
interface SearchDropdownProps {
8+
searchQuery: string
9+
sortBy: SortBy
10+
onSelectExtension: (extensionId: string) => void
11+
isVisible: boolean
12+
onClose: () => void
13+
}
14+
15+
function SearchDropdown({ searchQuery, sortBy, onSelectExtension, isVisible, onClose }: SearchDropdownProps) {
16+
const [results, setResults] = useState<SearchResult[]>([])
17+
const [isLoading, setIsLoading] = useState(false)
18+
const [error, setError] = useState<string | null>(null)
19+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
20+
const dropdownRef = useRef<HTMLDivElement>(null)
21+
22+
// Perform search when query changes
23+
useEffect(() => {
24+
const performSearch = async () => {
25+
if (!searchQuery || searchQuery.trim().length < 2) {
26+
setResults([])
27+
setIsLoading(false)
28+
return
29+
}
30+
31+
setIsLoading(true)
32+
setError(null)
33+
setHighlightedIndex(-1)
34+
35+
try {
36+
const searchResults = await searchExtensions(searchQuery, 10, sortBy)
37+
setResults(searchResults)
38+
} catch (err) {
39+
const errorMessage = err instanceof Error ? err.message : 'Failed to search extensions. Please try again.'
40+
setError(errorMessage)
41+
console.error('Search error:', err)
42+
} finally {
43+
setIsLoading(false)
44+
}
45+
}
46+
47+
// Debounce search
48+
const timeoutId = setTimeout(performSearch, 300)
49+
return () => clearTimeout(timeoutId)
50+
}, [searchQuery, sortBy])
51+
52+
// Handle click outside to close dropdown
53+
useEffect(() => {
54+
const handleClickOutside = (event: MouseEvent) => {
55+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
56+
onClose()
57+
}
58+
}
59+
60+
if (isVisible) {
61+
document.addEventListener('mousedown', handleClickOutside)
62+
return () => document.removeEventListener('mousedown', handleClickOutside)
63+
}
64+
}, [isVisible, onClose])
65+
66+
const handleSelectExtension = (extensionId: string) => {
67+
onSelectExtension(extensionId)
68+
onClose()
69+
setResults([])
70+
}
71+
72+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
73+
if (!isVisible || results.length === 0) return
74+
75+
switch (event.key) {
76+
case 'ArrowDown':
77+
event.preventDefault()
78+
setHighlightedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev))
79+
break
80+
case 'ArrowUp':
81+
event.preventDefault()
82+
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0))
83+
break
84+
case 'Enter':
85+
event.preventDefault()
86+
if (highlightedIndex >= 0 && highlightedIndex < results.length) {
87+
handleSelectExtension(results[highlightedIndex].extensionId)
88+
}
89+
break
90+
case 'Escape':
91+
event.preventDefault()
92+
onClose()
93+
break
94+
}
95+
}
96+
97+
if (!isVisible) {
98+
return null
99+
}
100+
101+
const showResults = !isLoading && !error && results.length > 0
102+
103+
return (
104+
<div className={styles.searchDropdown} ref={dropdownRef} onKeyDown={handleKeyDown}>
105+
<div className={styles.searchResults}>
106+
{isLoading && (
107+
<div className={styles.searchLoading}>
108+
<div className={styles.spinner}></div>
109+
<span>Searching marketplace...</span>
110+
</div>
111+
)}
112+
113+
{error && <div className={styles.searchError}>{error}</div>}
114+
115+
{!isLoading && !error && results.length === 0 && searchQuery.trim().length >= 2 && (
116+
<div className={styles.searchEmpty}>
117+
<div className={styles.searchEmptyIcon}>🔍</div>
118+
<div>No extensions found for "{searchQuery}"</div>
119+
</div>
120+
)}
121+
122+
{showResults && (
123+
<ul className={styles.searchResultsList} role="listbox" aria-label="Extension search results">
124+
{results.map((result, index) => (
125+
<li
126+
key={result.extensionId}
127+
className={styles.searchResultItem}
128+
onClick={() => handleSelectExtension(result.extensionId)}
129+
onMouseEnter={() => setHighlightedIndex(index)}
130+
data-highlighted={highlightedIndex === index}
131+
role="option"
132+
aria-selected={highlightedIndex === index}
133+
>
134+
<div className={styles.resultHeader}>
135+
<div className={styles.resultTitle}>
136+
<span>{result.displayName}</span>
137+
<span className={styles.extensionId}>{result.extensionId}</span>
138+
</div>
139+
<div className={styles.resultStats}>
140+
<div className={styles.installCount}>
141+
<span>📥</span>
142+
<span>{formatInstallCount(result.installs)}</span>
143+
</div>
144+
</div>
145+
</div>
146+
{result.shortDescription && (
147+
<p className={styles.resultDescription}>{result.shortDescription}</p>
148+
)}
149+
</li>
150+
))}
151+
</ul>
152+
)}
153+
</div>
154+
</div>
155+
)
156+
}
157+
158+
export default SearchDropdown

0 commit comments

Comments
 (0)