Skip to content

Commit 83e3de6

Browse files
authored
feat: search improvements (#733)
1 parent d573553 commit 83e3de6

File tree

8 files changed

+116
-16
lines changed

8 files changed

+116
-16
lines changed

app/components/Header/SearchBox.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ function handleSearchFocus() {
7979
emit('focus')
8080
}
8181
82+
function handleSubmit() {
83+
if (pagesWithLocalFilter.has(route.name as string)) {
84+
router.push({
85+
name: 'search',
86+
query: {
87+
q: searchQuery.value,
88+
},
89+
})
90+
} else {
91+
updateUrlQuery.flush()
92+
}
93+
}
94+
8295
// Expose focus method for parent components
8396
const inputRef = useTemplateRef('inputRef')
8497
function focus() {
@@ -88,7 +101,7 @@ defineExpose({ focus })
88101
</script>
89102
<template>
90103
<search v-if="showSearchBar" :class="'flex-1 sm:max-w-md ' + inputClass">
91-
<form method="GET" action="/search" class="relative">
104+
<form method="GET" action="/search" class="relative" @submit.prevent="handleSubmit">
92105
<label for="header-search" class="sr-only">
93106
{{ $t('search.label') }}
94107
</label>

app/components/Package/Card.vue

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import type { StructuredFilters } from '#shared/types/preferences'
3+
24
const props = defineProps<{
35
/** The search result object containing package data */
46
result: NpmSearchResult
@@ -8,10 +10,16 @@ const props = defineProps<{
810
showPublisher?: boolean
911
prefetch?: boolean
1012
index?: number
13+
/** Filters to apply to the results */
14+
filters?: StructuredFilters
1115
/** Search query for highlighting exact matches */
1216
searchQuery?: string
1317
}>()
1418
19+
const emit = defineEmits<{
20+
clickKeyword: [keyword: string]
21+
}>()
22+
1523
/** Check if this package is an exact match for the search query */
1624
const isExactMatch = computed(() => {
1725
if (!props.searchQuery) return false
@@ -149,14 +157,29 @@ const pkgDescription = useMarkdown(() => ({
149157
</div>
150158
</div>
151159

152-
<ul
160+
<div
153161
v-if="result.package.keywords?.length"
154162
:aria-label="$t('package.card.keywords')"
155-
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0"
163+
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none"
156164
>
157-
<li v-for="keyword in result.package.keywords.slice(0, 5)" :key="keyword" class="tag">
165+
<button
166+
v-for="keyword in result.package.keywords.slice(0, 5)"
167+
:key="keyword"
168+
type="button"
169+
class="tag text-xs hover:bg-fg hover:text-bg hover:border-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1 border-solid pointer-events-auto"
170+
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
171+
:title="`Filter by ${keyword}`"
172+
@click.stop="emit('clickKeyword', keyword)"
173+
>
158174
{{ keyword }}
159-
</li>
160-
</ul>
175+
</button>
176+
<span
177+
v-if="result.package.keywords.length > 5"
178+
class="tag text-fg-subtle text-xs border-none bg-transparent pointer-events-auto"
179+
:title="result.package.keywords.slice(5).join(', ')"
180+
>
181+
+{{ result.package.keywords.length - 5 }}
182+
</span>
183+
</div>
161184
</BaseCard>
162185
</template>

app/components/Package/List.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const SSR_COUNT = 20
1717
const props = defineProps<{
1818
/** List of search results to display */
1919
results: NpmSearchResult[]
20+
/** Filters to apply to the results */
21+
filters?: StructuredFilters
2022
/** Heading level for package names */
2123
headingLevel?: 'h2' | 'h3'
2224
/** Whether to show publisher username on cards */
@@ -39,6 +41,8 @@ const props = defineProps<{
3941
paginationMode?: PaginationMode
4042
/** Current page (1-indexed) for paginated mode */
4143
currentPage?: number
44+
/** When true, shows search-specific UI (relevance sort, no filters) */
45+
searchContext?: boolean
4246
}>()
4347
4448
const emit = defineEmits<{
@@ -60,7 +64,11 @@ const sortOption = defineModel<SortOption>('sortOption')
6064
6165
// View mode and columns
6266
const viewMode = computed(() => props.viewMode ?? 'cards')
63-
const columns = computed(() => props.columns ?? DEFAULT_COLUMNS)
67+
const columns = computed(() => {
68+
const targetColumns = props.columns ?? DEFAULT_COLUMNS
69+
if (props.searchContext) return targetColumns.map(column => ({ ...column, sortable: false }))
70+
return targetColumns
71+
})
6472
// Table view forces pagination mode (no virtualization for tables)
6573
const paginationMode = computed(() =>
6674
viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'),
@@ -147,6 +155,7 @@ defineExpose({
147155
<template v-if="viewMode === 'table'">
148156
<PackageTable
149157
:results="displayedResults"
158+
:filters="filters"
150159
:columns="columns"
151160
v-model:sort-option="sortOption"
152161
:is-loading="isLoading"
@@ -176,7 +185,9 @@ defineExpose({
176185
:index="index"
177186
:search-query="searchQuery"
178187
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
188+
:filters="filters"
179189
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
190+
@click-keyword="emit('clickKeyword', $event)"
180191
/>
181192
</div>
182193
</template>
@@ -193,6 +204,8 @@ defineExpose({
193204
:show-publisher="showPublisher"
194205
:index="index"
195206
:search-query="searchQuery"
207+
:filters="filters"
208+
@click-keyword="emit('clickKeyword', $event)"
196209
/>
197210
</div>
198211
</li>
@@ -225,6 +238,8 @@ defineExpose({
225238
:search-query="searchQuery"
226239
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
227240
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
241+
:filters="filters"
242+
@click-keyword="emit('clickKeyword', $event)"
228243
/>
229244
</li>
230245
</ol>

app/components/Package/Table.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
<script setup lang="ts">
22
import type { NpmSearchResult } from '#shared/types/npm-registry'
3-
import type { ColumnConfig, ColumnId, SortKey, SortOption } from '#shared/types/preferences'
3+
import type {
4+
ColumnConfig,
5+
ColumnId,
6+
SortKey,
7+
SortOption,
8+
StructuredFilters,
9+
} from '#shared/types/preferences'
410
import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types/preferences'
511
612
const props = defineProps<{
713
results: NpmSearchResult[]
814
columns: ColumnConfig[]
15+
filters?: StructuredFilters
916
isLoading?: boolean
1017
}>()
1118
@@ -317,6 +324,7 @@ function getColumnLabelKey(id: ColumnId): string {
317324
:result="result"
318325
:columns="columns"
319326
:index="index"
327+
:filters="filters"
320328
@click-keyword="emit('clickKeyword', $event)"
321329
/>
322330
</template>

app/components/Package/TableRow.vue

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
<script setup lang="ts">
22
import type { NpmSearchResult } from '#shared/types/npm-registry'
3-
import type { ColumnConfig } from '#shared/types/preferences'
3+
import type { ColumnConfig, StructuredFilters } from '#shared/types/preferences'
44
55
const props = defineProps<{
66
result: NpmSearchResult
77
columns: ColumnConfig[]
88
index?: number
9+
filters?: StructuredFilters
910
}>()
1011
1112
const emit = defineEmits<{
@@ -117,18 +118,27 @@ const allMaintainersText = computed(() => {
117118

118119
<!-- Keywords -->
119120
<td v-if="isColumnVisible('keywords')" class="py-2 px-3">
120-
<div v-if="pkg.keywords?.length" class="flex flex-wrap gap-1">
121+
<div
122+
v-if="pkg.keywords?.length"
123+
class="flex flex-wrap gap-1"
124+
:aria-label="$t('package.card.keywords')"
125+
>
121126
<button
122127
v-for="keyword in pkg.keywords.slice(0, 3)"
123128
:key="keyword"
124129
type="button"
125-
class="tag text-xs hover:bg-fg hover:text-bg hover:border-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
130+
class="tag text-xs hover:bg-fg hover:text-bg hover:border-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1 border-solid"
131+
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
126132
:title="`Filter by ${keyword}`"
127133
@click.stop="emit('clickKeyword', keyword)"
128134
>
129135
{{ keyword }}
130136
</button>
131-
<span v-if="pkg.keywords.length > 3" class="text-fg-subtle text-xs">
137+
<span
138+
v-if="pkg.keywords.length > 3"
139+
class="tag text-fg-subtle text-xs border-none bg-transparent"
140+
:title="pkg.keywords.slice(3).join(', ')"
141+
>
132142
+{{ pkg.keywords.length - 3 }}
133143
</span>
134144
</div>

app/composables/useStructuredFilters.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,22 @@ function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolea
112112
*
113113
*/
114114
export function useStructuredFilters(options: UseStructuredFiltersOptions) {
115+
const route = useRoute()
116+
const router = useRouter()
115117
const { packages, initialFilters, initialSort } = options
116118
const { t } = useI18n()
117119

120+
const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
121+
watch(
122+
() => route.query.q,
123+
urlQuery => {
124+
const value = normalizeSearchParam(urlQuery)
125+
if (searchQuery.value !== value) {
126+
searchQuery.value = value
127+
}
128+
},
129+
)
130+
118131
// Filter state
119132
const filters = ref<StructuredFilters>({
120133
...DEFAULT_FILTERS,
@@ -387,11 +400,17 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
387400
function addKeyword(keyword: string) {
388401
if (!filters.value.keywords.includes(keyword)) {
389402
filters.value.keywords = [...filters.value.keywords, keyword]
403+
const newQ = searchQuery.value
404+
? `${searchQuery.value.trim()} keyword:${keyword}`
405+
: `keyword:${keyword}`
406+
router.replace({ query: { ...route.query, q: newQ } })
390407
}
391408
}
392409

393410
function removeKeyword(keyword: string) {
394411
filters.value.keywords = filters.value.keywords.filter(k => k !== keyword)
412+
const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim()
413+
router.replace({ query: { ...route.query, q: newQ || undefined } })
395414
}
396415

397416
function toggleKeyword(keyword: string) {

app/pages/@[org].vue

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const {
5252
} = useStructuredFilters({
5353
packages,
5454
initialFilters: {
55-
text: normalizeSearchParam(route.query.q),
55+
...parseSearchOperators(normalizeSearchParam(route.query.q)),
5656
},
5757
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc',
5858
})
@@ -91,9 +91,15 @@ const updateUrl = debounce((updates: { filter?: string; sort?: string }) => {
9191
}, 300)
9292
9393
// Update URL when filter/sort changes (debounced)
94-
watch([() => filters.value.text, sortOption], ([filter, sort]) => {
95-
updateUrl({ filter, sort })
96-
})
94+
watch(
95+
[() => filters.value.text, () => filters.value.keywords, () => sortOption.value] as const,
96+
([text, keywords, sort]) => {
97+
const filter = [text, ...keywords.map(keyword => `keyword:${keyword}`)]
98+
.filter(Boolean)
99+
.join(' ')
100+
updateUrl({ filter, sort })
101+
},
102+
)
97103
98104
const filteredCount = computed(() => sortedPackages.value.length)
99105
@@ -282,6 +288,7 @@ defineOgImageComponent('Default', {
282288
:results="sortedPackages"
283289
:view-mode="viewMode"
284290
:columns="columns"
291+
:filters="filters"
285292
v-model:sort-option="sortOption"
286293
:pagination-mode="paginationMode"
287294
:page-size="pageSize"

app/pages/search.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ const {
142142
clearAllFilters,
143143
} = useStructuredFilters({
144144
packages: resultsArray,
145+
initialFilters: {
146+
...parseSearchOperators(normalizeSearchParam(route.query.q)),
147+
},
145148
initialSort: 'relevance-desc', // Default to search relevance
146149
})
147150
@@ -737,6 +740,8 @@ defineOgImageComponent('Default', {
737740
v-if="displayResults.length > 0"
738741
:results="displayResults"
739742
:search-query="query"
743+
:filters="filters"
744+
search-context
740745
heading-level="h2"
741746
show-publisher
742747
:has-more="hasMore"

0 commit comments

Comments
 (0)