Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
27 changes: 23 additions & 4 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ const isMobile = useIsMobile()
const isSearchExpandedManually = shallowRef(false)
const searchBoxRef = useTemplateRef('searchBoxRef')

const { searchQuery, updateSearchQuery } = usePackageSearchQuery()

const router = useRouter()
function handleSubmitSearch(queryValue: string) {
if (queryValue === '') {
return
}

router.replace({
name: 'search',
query: { ...route.query, q: queryValue },
})
}

// On search page, always show search expanded on mobile
const isOnHomePage = computed(() => route.name === 'index')
const isOnSearchPage = computed(() => route.name === 'search')
Expand Down Expand Up @@ -90,7 +104,7 @@ onKeyStroke(
<div class="absolute inset-0 bg-bg/80 backdrop-blur-md" />
<nav
:aria-label="$t('nav.main_navigation')"
class="relative container min-h-14 flex items-center gap-2 z-1"
class="relative container min-h-14 flex items-center gap-4 z-1"
:class="isOnHomePage ? 'justify-end' : 'justify-between'"
>
<!-- Mobile: Logo + search button (expands search, doesn't navigate) -->
Expand Down Expand Up @@ -130,13 +144,18 @@ onKeyStroke(
}"
>
<!-- Search bar (hidden on mobile unless expanded) -->
<HeaderSearchBox
<SearchBox
v-if="!isOnHomePage"
ref="searchBoxRef"
:inputClass="isSearchExpanded ? 'w-full' : ''"
:class="{ 'max-w-md': !isSearchExpanded }"
class="max-w-sm"
compact
:model-value="searchQuery"
@update:model-value="updateSearchQuery"
@submit="handleSubmitSearch"
@focus="handleSearchFocus"
@blur="handleSearchBlur"
/>

<ul
v-if="!isSearchExpanded && isConnected && npmUser"
:class="{ hidden: showFullSearch }"
Expand Down
126 changes: 0 additions & 126 deletions app/components/Header/SearchBox.vue

This file was deleted.

95 changes: 95 additions & 0 deletions app/components/SearchBox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script setup lang="ts">
defineProps<{
compact?: boolean
}>()

const emit = defineEmits<{
(e: 'submit', searchQuery: string): void
(e: 'blur'): void
(e: 'focus'): void
}>()

const searchQuery = defineModel<string>({
default: '',
})

function handleSubmit() {
emit('submit', searchQuery.value)
}

function handleBlur() {
emit('blur')
}
function handleFocus() {
emit('focus')
}

// Expose focus method for parent components
const inputRef = useTemplateRef('inputRef')
function focus() {
inputRef.value?.focus()
}

defineExpose({
focus,
})
</script>

<template>
<search class="w-full @container">
<form method="GET" action="/search" class="relative" @submit.prevent="handleSubmit">
<label for="search-box" class="sr-only">
{{ $t('search.label') }}
</label>

<div class="relative group">
<div
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100"
/>

<div class="search-box relative flex items-center">
<span
class="absolute text-fg-subtle font-mono pointer-events-none transition-colors duration-200 motion-reduce:transition-none [.group:hover:not(:focus-within)_&]:text-fg/80 group-focus-within:text-accent z-1"
:class="compact ? 'inset-is-3 text-sm' : 'inset-is-4 text-xl'"
>
/
</span>

<input
id="search-box"
ref="inputRef"
v-model.trim="searchQuery"
type="search"
name="q"
:placeholder="$t('search.placeholder')"
v-bind="noCorrect"
class="w-full bg-bg-subtle border border-border text-base font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 motion-reduce:transition-none hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
:class="
compact ? 'ps-7 pe-3 py-1.5 rounded-md text-sm!' : 'ps-8 pe-24 h-14 py-4 rounded-xl'
"
@blur="handleBlur"
@focus="handleFocus"
/>

<button
type="submit"
class="absolute hidden @xs:block group inset-ie-2.5 font-mono text-sm transition-[background-color,transform] duration-200 active:scale-95"
:class="
compact
? 'px-1.5 py-0.5 @md:ps-4 @md:pe-4'
: 'rounded-md px-2.5 @md:ps-4 @md:pe-4 py-2 text-bg bg-fg/90 hover:bg-fg! group-focus-within:bg-fg/80'
"
>
<span
class="inline-block i-carbon:search align-middle w-4 h-4 @md:me-2"
aria-hidden="true"
></span>
<span class="sr-only @md:not-sr-only">
{{ $t('search.button') }}
</span>
</button>
</div>
</div>
</form>
</search>
</template>
7 changes: 7 additions & 0 deletions app/composables/npm/useNpmSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function packumentToSearchResult(
export interface NpmSearchOptions {
/** Number of results to fetch */
size?: number
onResponse?: (result: { query: string }) => void
}

export const emptySearchResponse = {
Expand Down Expand Up @@ -99,6 +100,8 @@ export function useNpmSearch(
size: opts.size ?? 25,
})

opts.onResponse?.({ query: q })

if (q !== toValue(query)) {
return emptySearchResponse
}
Comment on lines +103 to 107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

onResponse invoked before stale query check may cause URL desync.

The callback fires before verifying q !== toValue(query). If the user types quickly, the callback updates the URL with an outdated query, then the response is discarded as stale—leaving the URL out of sync with the actual search input.

Consider moving the callback invocation after the stale check, or rename to onResponseReceived if the current timing is intentional.

Proposed fix
         const response = await searchAlgolia(q, {
           size: opts.size ?? 25,
         })

-        opts.onResponse?.({ query: q })
-
         if (q !== toValue(query)) {
           return emptySearchResponse
         }

+        opts.onResponse?.({ query: q })
+
         isRateLimited.value = false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
opts.onResponse?.({ query: q })
if (q !== toValue(query)) {
return emptySearchResponse
}
if (q !== toValue(query)) {
return emptySearchResponse
}
opts.onResponse?.({ query: q })
isRateLimited.value = false

Expand Down Expand Up @@ -130,6 +133,8 @@ export function useNpmSearch(
}),
])

opts.onResponse?.({ query: q })

if (!pkg) {
return emptySearchResponse
}
Expand Down Expand Up @@ -165,6 +170,8 @@ export function useNpmSearch(
60,
)

opts.onResponse?.({ query: q })

// If query changed/outdated, return empty search response
if (q !== toValue(query)) {
return emptySearchResponse
Expand Down
21 changes: 21 additions & 0 deletions app/composables/usePackageSearchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { debounce } from 'perfect-debounce'

export function usePackageSearchQuery() {
const searchQuery = useState<string>('package_search_query', () => '')

const route = useRoute()
onMounted(() => {
if (route.query.q) {
searchQuery.value = normalizeSearchParam(route.query.q)
}
})

const updateSearchQuery = debounce((newSearchQuery: string) => {
searchQuery.value = newSearchQuery
}, 400)

return {
searchQuery,
updateSearchQuery,
}
}
Comment on lines +1 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for normalizeSearchParam definition and usage
rg "normalizeSearchParam" --type=ts --type=vue -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check the actual imports in usePackageSearchQuery.ts
cat app/composables/usePackageSearchQuery.ts

Repository: npmx-dev/npmx.dev

Length of output: 541


🏁 Script executed:

# Check nuxt.config for auto-import configuration
fd "nuxt.config" -type f | xargs cat

Repository: npmx-dev/npmx.dev

Length of output: 231


🏁 Script executed:

# Search for normalizeSearchParam definition
rg "normalizeSearchParam" -A3 -B3

Repository: npmx-dev/npmx.dev

Length of output: 5756


🏁 Script executed:

# Find nuxt.config files
find . -name "nuxt.config*" -type f

Repository: npmx-dev/npmx.dev

Length of output: 99


🏁 Script executed:

# Check for utils/url.ts where normalizeSearchParam might be defined
find app -name "url.ts" -o -name "url.js"

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check nuxt.config for auto-import configuration
cat -n nuxt.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 9567


🏁 Script executed:

# Check the useStructuredFilters composable which also uses normalizeSearchParam
cat app/composables/useStructuredFilters.ts

Repository: npmx-dev/npmx.dev

Length of output: 14869


Add missing import for normalizeSearchParam.

The composable uses normalizeSearchParam (line 8) without importing it. Add import { normalizeSearchParam } from '#shared/utils/url' at the top of the file.

Loading
Loading