Skip to content

Commit 92d89b8

Browse files
committed
feat: make instant search optional
1 parent d1e8bf7 commit 92d89b8

File tree

9 files changed

+103
-13
lines changed

9 files changed

+103
-13
lines changed

app/components/Compare/PackageSelector.vue

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const maxPackages = computed(() => props.max ?? 4)
1212
1313
// Input state
1414
const inputValue = shallowRef('')
15+
const committedInput = shallowRef('')
1516
const isInputFocused = shallowRef(false)
1617
1718
// Keyboard navigation state
@@ -21,7 +22,20 @@ const PAGE_JUMP = 5
2122
2223
// Use the shared search composable (supports both npm and Algolia providers)
2324
const { searchProvider } = useSearchProvider()
24-
const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 })
25+
const { settings } = useSettings()
26+
27+
// When instantSearch is off, only search after Enter is pressed
28+
watch(inputValue, val => {
29+
if (settings.value.instantSearch) {
30+
committedInput.value = val
31+
}
32+
33+
if (!val) {
34+
committedInput.value = ''
35+
}
36+
})
37+
38+
const { data: searchData, status } = useSearch(committedInput, searchProvider, { size: 15 })
2539
2640
const isSearching = computed(() => status.value === 'pending')
2741
@@ -50,7 +64,9 @@ const showNoDependencyOption = computed(() => {
5064
5165
// Filter out already selected packages
5266
const filteredResults = computed(() => {
53-
if (!searchData.value?.objects) return []
67+
// Intentionally clear results if input is empty
68+
if (!committedInput.value || !searchData.value?.objects) return []
69+
5470
return searchData.value.objects
5571
.map(o => ({
5672
name: o.package.name,
@@ -150,6 +166,12 @@ function handleKeydown(e: KeyboardEvent) {
150166
151167
e.preventDefault()
152168
169+
// When instant search is off, first Enter commits the query to trigger search
170+
if (!settings.value.instantSearch && committedInput.value !== inputValueTrim) {
171+
committedInput.value = inputValueTrim
172+
return
173+
}
174+
153175
// If an item is highlighted, select it
154176
if (highlightedIndex.value >= 0 && highlightedIndex.value < count) {
155177
addPackage(items[highlightedIndex.value]!.name)

app/components/Header/SearchBox.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ const showSearchBar = computed(() => {
1616
return route.name !== 'index'
1717
})
1818
19-
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch('header')
19+
const { model: searchQuery, startSearch } = useGlobalSearch('header')
2020
2121
function handleSubmit() {
22-
flushUpdateUrlQuery()
22+
startSearch()
2323
}
2424
2525
// Expose focus method for parent components

app/composables/useGlobalSearch.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,42 @@ import { debounce } from 'perfect-debounce'
55
const pagesWithLocalFilter = new Set(['~username', 'org'])
66

77
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
8+
const { settings } = useSettings()
89
const { searchProvider } = useSearchProvider()
910
const searchProviderValue = computed(() => {
1011
const p = normalizeSearchParam(route.query.p)
1112
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
1213
return 'algolia'
1314
})
15+
1416
const router = useRouter()
1517
const route = useRoute()
18+
// Internally used searchQuery state
1619
const searchQuery = useState<string>('search-query', () => {
1720
if (pagesWithLocalFilter.has(route.name as string)) {
1821
return ''
1922
}
2023
return normalizeSearchParam(route.query.q)
2124
})
2225

26+
// Committed searchQuery: last value submitted by user
27+
// Syncs instantly when instant search is on VS only on Enter presswhen off
28+
const committedSearchQuery = useState<string>('committed-search-query', () => {
29+
if (pagesWithLocalFilter.has(route.name as string)) {
30+
return ''
31+
}
32+
return normalizeSearchParam(route.query.q)
33+
})
34+
35+
watch(searchQuery, val => {
36+
if (settings.value.instantSearch) {
37+
committedSearchQuery.value = val
38+
} else if (!val) {
39+
// Only clear committed query when input is cleared
40+
committedSearchQuery.value = ''
41+
}
42+
})
43+
2344
// clean search input when navigating away from search page
2445
watch(
2546
() => route.query.q,
@@ -29,6 +50,8 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
2950
if (!searchQuery.value) searchQuery.value = value
3051
},
3152
)
53+
54+
// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
3255
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
3356
const isSameQuery = route.query.q === value && route.query.p === provider
3457
// Don't navigate away from pages that use ?q for local filtering
@@ -54,23 +77,40 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
5477
},
5578
})
5679
}
80+
5781
const updateUrlQuery = debounce(updateUrlQueryImpl, 250)
5882

5983
function flushUpdateUrlQuery() {
60-
updateUrlQuery.flush()
84+
// Commit the current query when explicitly submitted (Enter pressed)
85+
committedSearchQuery.value = searchQuery.value
86+
// When instant search is off the debounce queue is empty, so call directly
87+
if (!settings.value.instantSearch) {
88+
updateUrlQueryImpl(searchQuery.value, searchProvider.value)
89+
} else {
90+
updateUrlQuery.flush()
91+
}
6192
}
6293

6394
const searchQueryValue = computed({
6495
get: () => searchQuery.value,
6596
set: async (value: string) => {
6697
searchQuery.value = value
6798

99+
// When instant search is off, skip debounced URL updates — only flushUpdateUrlQuery commits and navigates
100+
if (!settings.value.instantSearch) return
101+
68102
// Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43)
69103
if (!updateUrlQuery.isPending()) {
70104
updateUrlQueryImpl(value, searchProvider.value)
71105
}
72106
updateUrlQuery(value, searchProvider.value)
73107
},
74108
})
75-
return { model: searchQueryValue, provider: searchProviderValue, flushUpdateUrlQuery }
109+
110+
return {
111+
model: searchQueryValue,
112+
committedModel: committedSearchQuery,
113+
provider: searchProviderValue,
114+
startSearch: flushUpdateUrlQuery,
115+
}
76116
}

app/composables/useSettings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface AppSettings {
2929
selectedLocale: LocaleObject['code'] | null
3030
/** Search provider for package search */
3131
searchProvider: SearchProvider
32+
/** Show search results as you type */
33+
instantSearch: boolean
3234
/** Enable/disable keyboard shortcuts */
3335
keyboardShortcuts: boolean
3436
/** Connector preferences */
@@ -54,6 +56,7 @@ const DEFAULT_SETTINGS: AppSettings = {
5456
selectedLocale: null,
5557
preferredBackgroundTheme: null,
5658
searchProvider: import.meta.test ? 'npm' : 'algolia',
59+
instantSearch: true,
5760
keyboardShortcuts: true,
5861
connector: {
5962
autoOpenURL: false,

app/pages/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script setup lang="ts">
22
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'
33
4-
const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()
4+
const { model: searchQuery, startSearch } = useGlobalSearch()
55
const isSearchFocused = shallowRef(false)
66
77
async function search() {
8-
flushUpdateUrlQuery()
8+
startSearch()
99
}
1010
1111
const { env } = useAppConfig().buildInfo

app/pages/search.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ const updateUrlPage = debounce((page: number) => {
3232
window.history.replaceState(window.history.state, '', url)
3333
}, 500)
3434
35-
const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
35+
const {
36+
model: searchQuery,
37+
committedModel: committedQuery,
38+
provider: searchProvider,
39+
} = useGlobalSearch()
3640
const query = computed(() => searchQuery.value)
3741
3842
// Track if page just loaded (for hiding "Searching..." during view transition)
@@ -181,6 +185,7 @@ watch(searchProvider, provider => {
181185
})
182186
183187
// Use incremental search with client-side caching + org/user suggestions
188+
// committedQuery only updates on Enter when instant search is off, otherwise tracks query as user types
184189
const {
185190
data: results,
186191
status,
@@ -191,7 +196,7 @@ const {
191196
suggestions: validatedSuggestions,
192197
packageAvailability,
193198
} = useSearch(
194-
query,
199+
committedQuery,
195200
searchProvider,
196201
() => ({
197202
size: requestedSize.value,
@@ -475,6 +480,9 @@ function handleResultsKeydown(e: KeyboardEvent) {
475480
const inputValue = (document.activeElement as HTMLInputElement).value.trim()
476481
if (!inputValue) return
477482
483+
// When instantSearch is off, commit the query so search starts
484+
committedQuery.value = inputValue
485+
478486
// Check if first result matches the input value exactly
479487
const firstResult = displayResults.value[0]
480488
if (firstResult?.package.name === inputValue) {
@@ -664,7 +672,7 @@ onBeforeUnmount(() => {
664672
<SearchProviderToggle />
665673
</div>
666674

667-
<section v-if="query" class="results-layout">
675+
<section v-if="committedQuery" class="results-layout">
668676
<LoadingSpinner v-if="showSearching" :text="$t('search.searching')" />
669677

670678
<div

app/pages/settings.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const setLocale: typeof setNuxti18nLocale = locale => {
145145
</div>
146146
</section>
147147

148-
<!-- DATA SOURCE Section -->
148+
<!-- SEARCH FEATURES Section -->
149149
<section>
150150
<h2 class="text-xs text-fg-muted uppercase tracking-wider mb-4">
151151
{{ $t('settings.sections.search') }}
@@ -204,6 +204,15 @@ const setLocale: typeof setNuxti18nLocale = locale => {
204204
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
205205
</a>
206206
</div>
207+
208+
<div class="border-t border-border my-4" />
209+
210+
<!-- Instant Search toggle -->
211+
<SettingsToggle
212+
:label="$t('settings.instant_search')"
213+
:description="$t('settings.instant_search_description')"
214+
v-model="settings.instantSearch"
215+
/>
207216
</div>
208217
</section>
209218

i18n/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"sections": {
111111
"appearance": "Appearance",
112112
"display": "Display",
113-
"search": "Data source",
113+
"search": "Search features",
114114
"language": "Language",
115115
"keyboard_shortcuts": "Keyboard shortcuts"
116116
},
@@ -122,6 +122,8 @@
122122
"algolia": "Algolia",
123123
"algolia_description": "Uses Algolia for faster search, org and user pages."
124124
},
125+
"instant_search": "Instant search",
126+
"instant_search_description": "Show search results as you type, without pressing Enter.",
125127
"relative_dates": "Relative dates",
126128
"include_types": "Include {'@'}types in install",
127129
"include_types_description": "Add {'@'}types package to install commands for untyped packages",

i18n/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,12 @@
370370
},
371371
"additionalProperties": false
372372
},
373+
"instant_search": {
374+
"type": "string"
375+
},
376+
"instant_search_description": {
377+
"type": "string"
378+
},
373379
"relative_dates": {
374380
"type": "string"
375381
},

0 commit comments

Comments
 (0)