Skip to content

Commit 2786639

Browse files
alex-keyknowler
andauthored
feat: instant search becomes optional (#1851)
Co-authored-by: Nathan Knowler <nathan@knowler.dev>
1 parent b3af9c6 commit 2786639

File tree

8 files changed

+73
-11
lines changed

8 files changed

+73
-11
lines changed

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: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,35 @@ 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 search query: last value submitted by user
27+
// Syncs instantly when instantSearch is on, but only on Enter press when off
28+
const committedSearchQuery = useState<string>('committed-search-query', () => searchQuery.value)
29+
30+
// This is basically doing instant search as user types
31+
watch(searchQuery, val => {
32+
if (settings.value.instantSearch) {
33+
committedSearchQuery.value = val
34+
}
35+
})
36+
2337
// clean search input when navigating away from search page
2438
watch(
2539
() => route.query.q,
@@ -29,6 +43,8 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
2943
if (!searchQuery.value) searchQuery.value = value
3044
},
3145
)
46+
47+
// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
3248
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
3349
const isSameQuery = route.query.q === value && route.query.p === provider
3450
// Don't navigate away from pages that use ?q for local filtering
@@ -54,23 +70,41 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
5470
},
5571
})
5672
}
73+
5774
const updateUrlQuery = debounce(updateUrlQueryImpl, 250)
5875

5976
function flushUpdateUrlQuery() {
60-
updateUrlQuery.flush()
77+
// Commit the current query when explicitly submitted (Enter pressed)
78+
committedSearchQuery.value = searchQuery.value
79+
// When instant search is off the debounce queue is empty, so call directly
80+
if (!settings.value.instantSearch) {
81+
updateUrlQueryImpl(searchQuery.value, searchProvider.value)
82+
} else {
83+
updateUrlQuery.flush()
84+
}
6185
}
6286

6387
const searchQueryValue = computed({
6488
get: () => searchQuery.value,
6589
set: async (value: string) => {
6690
searchQuery.value = value
6791

92+
// When instant search is off, skip debounced URL updates
93+
// Only explicitly called flushUpdateUrlQuery commits and navigates
94+
if (!settings.value.instantSearch) return
95+
6896
// Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43)
6997
if (!updateUrlQuery.isPending()) {
7098
updateUrlQueryImpl(value, searchProvider.value)
7199
}
72100
updateUrlQuery(value, searchProvider.value)
73101
},
74102
})
75-
return { model: searchQueryValue, provider: searchProviderValue, flushUpdateUrlQuery }
103+
104+
return {
105+
model: searchQueryValue,
106+
committedModel: committedSearchQuery,
107+
provider: searchProviderValue,
108+
startSearch: flushUpdateUrlQuery,
109+
}
76110
}

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": "Navigates to the search page and updates the results as you type.",
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)