Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ const showSearchBar = computed(() => {
return route.name !== 'index'
})

const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch('header')
const { model: searchQuery, startSearch } = useGlobalSearch('header')

function handleSubmit() {
flushUpdateUrlQuery()
startSearch()
}

// Expose focus method for parent components
Expand Down
38 changes: 36 additions & 2 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,35 @@ import { debounce } from 'perfect-debounce'
const pagesWithLocalFilter = new Set(['~username', 'org'])

export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
const p = normalizeSearchParam(route.query.p)
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
return 'algolia'
})

const router = useRouter()
const route = useRoute()
// Internally used searchQuery state
const searchQuery = useState<string>('search-query', () => {
if (pagesWithLocalFilter.has(route.name as string)) {
return ''
}
return normalizeSearchParam(route.query.q)
})

// Committed search query: last value submitted by user
// Syncs instantly when instantSearch is on, but only on Enter press when off
const committedSearchQuery = useState<string>('committed-search-query', () => searchQuery.value)

// This is basically doing instant search as user types
watch(searchQuery, val => {
if (settings.value.instantSearch) {
committedSearchQuery.value = val
}
})

// clean search input when navigating away from search page
watch(
() => route.query.q,
Expand All @@ -29,6 +43,8 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
if (!searchQuery.value) searchQuery.value = value
},
)

// Updates URL when search query changes (immediately for instantSearch or after Enter hit otherwise)
const updateUrlQueryImpl = (value: string, provider: 'npm' | 'algolia') => {
const isSameQuery = route.query.q === value && route.query.p === provider
// Don't navigate away from pages that use ?q for local filtering
Expand All @@ -54,23 +70,41 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
},
})
}

const updateUrlQuery = debounce(updateUrlQueryImpl, 250)

function flushUpdateUrlQuery() {
updateUrlQuery.flush()
// Commit the current query when explicitly submitted (Enter pressed)
committedSearchQuery.value = searchQuery.value
// When instant search is off the debounce queue is empty, so call directly
if (!settings.value.instantSearch) {
updateUrlQueryImpl(searchQuery.value, searchProvider.value)
} else {
updateUrlQuery.flush()
}
}

const searchQueryValue = computed({
get: () => searchQuery.value,
set: async (value: string) => {
searchQuery.value = value

// When instant search is off, skip debounced URL updates
// Only explicitly called flushUpdateUrlQuery commits and navigates
if (!settings.value.instantSearch) return

// Leading debounce implementation as it doesn't work properly out of the box (https://github.com/unjs/perfect-debounce/issues/43)
if (!updateUrlQuery.isPending()) {
updateUrlQueryImpl(value, searchProvider.value)
}
updateUrlQuery(value, searchProvider.value)
},
})
return { model: searchQueryValue, provider: searchProviderValue, flushUpdateUrlQuery }

return {
model: searchQueryValue,
committedModel: committedSearchQuery,
provider: searchProviderValue,
startSearch: flushUpdateUrlQuery,
}
}
3 changes: 3 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface AppSettings {
selectedLocale: LocaleObject['code'] | null
/** Search provider for package search */
searchProvider: SearchProvider
/** Show search results as you type */
instantSearch: boolean
/** Enable/disable keyboard shortcuts */
keyboardShortcuts: boolean
/** Connector preferences */
Expand All @@ -54,6 +56,7 @@ const DEFAULT_SETTINGS: AppSettings = {
selectedLocale: null,
preferredBackgroundTheme: null,
searchProvider: import.meta.test ? 'npm' : 'algolia',
instantSearch: true,
keyboardShortcuts: true,
connector: {
autoOpenURL: false,
Expand Down
4 changes: 2 additions & 2 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'

const { model: searchQuery, flushUpdateUrlQuery } = useGlobalSearch()
const { model: searchQuery, startSearch } = useGlobalSearch()
const isSearchFocused = shallowRef(false)

async function search() {
flushUpdateUrlQuery()
startSearch()
}

const { env } = useAppConfig().buildInfo
Expand Down
14 changes: 11 additions & 3 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const updateUrlPage = debounce((page: number) => {
window.history.replaceState(window.history.state, '', url)
}, 500)

const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
const {
model: searchQuery,
committedModel: committedQuery,
provider: searchProvider,
} = useGlobalSearch()
const query = computed(() => searchQuery.value)

// Track if page just loaded (for hiding "Searching..." during view transition)
Expand Down Expand Up @@ -181,6 +185,7 @@ watch(searchProvider, provider => {
})

// Use incremental search with client-side caching + org/user suggestions
// committedQuery only updates on Enter when instant search is off, otherwise tracks query as user types
const {
data: results,
status,
Expand All @@ -191,7 +196,7 @@ const {
suggestions: validatedSuggestions,
packageAvailability,
} = useSearch(
query,
committedQuery,
searchProvider,
() => ({
size: requestedSize.value,
Expand Down Expand Up @@ -475,6 +480,9 @@ function handleResultsKeydown(e: KeyboardEvent) {
const inputValue = (document.activeElement as HTMLInputElement).value.trim()
if (!inputValue) return

// When instantSearch is off, commit the query so search starts
committedQuery.value = inputValue

// Check if first result matches the input value exactly
const firstResult = displayResults.value[0]
if (firstResult?.package.name === inputValue) {
Expand Down Expand Up @@ -664,7 +672,7 @@ onBeforeUnmount(() => {
<SearchProviderToggle />
</div>

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

<div
Expand Down
11 changes: 10 additions & 1 deletion app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ const setLocale: typeof setNuxti18nLocale = locale => {
</div>
</section>

<!-- DATA SOURCE Section -->
<!-- SEARCH FEATURES Section -->
<section>
<h2 class="text-xs text-fg-muted uppercase tracking-wider mb-4">
{{ $t('settings.sections.search') }}
Expand Down Expand Up @@ -204,6 +204,15 @@ const setLocale: typeof setNuxti18nLocale = locale => {
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</div>

<div class="border-t border-border my-4" />

<!-- Instant Search toggle -->
<SettingsToggle
:label="$t('settings.instant_search')"
:description="$t('settings.instant_search_description')"
v-model="settings.instantSearch"
/>
</div>
</section>

Expand Down
4 changes: 3 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"sections": {
"appearance": "Appearance",
"display": "Display",
"search": "Data source",
"search": "Search features",
"language": "Language",
"keyboard_shortcuts": "Keyboard shortcuts"
},
Expand All @@ -122,6 +122,8 @@
"algolia": "Algolia",
"algolia_description": "Uses Algolia for faster search, org and user pages."
},
"instant_search": "Instant search",
"instant_search_description": "Show search results as you type, without pressing Enter.",
Comment thread
knowler marked this conversation as resolved.
Outdated
"relative_dates": "Relative dates",
"include_types": "Include {'@'}types in install",
"include_types_description": "Add {'@'}types package to install commands for untyped packages",
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,12 @@
},
"additionalProperties": false
},
"instant_search": {
"type": "string"
},
"instant_search_description": {
"type": "string"
},
"relative_dates": {
"type": "string"
},
Expand Down
Loading