[READ-ONLY] a fast, modern browser for the npm registry

feat: search improvements (#733)

authored by

Alex Savelyev and committed by
GitHub
83e3de62 d573553c

+116 -16
+14 -1
app/components/Header/SearchBox.vue
··· 79 79 emit('focus') 80 80 } 81 81 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 + 82 95 // Expose focus method for parent components 83 96 const inputRef = useTemplateRef('inputRef') 84 97 function focus() { ··· 88 101 </script> 89 102 <template> 90 103 <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"> 92 105 <label for="header-search" class="sr-only"> 93 106 {{ $t('search.label') }} 94 107 </label>
+28 -5
app/components/Package/Card.vue
··· 1 1 <script setup lang="ts"> 2 + import type { StructuredFilters } from '#shared/types/preferences' 3 + 2 4 const props = defineProps<{ 3 5 /** The search result object containing package data */ 4 6 result: NpmSearchResult ··· 8 10 showPublisher?: boolean 9 11 prefetch?: boolean 10 12 index?: number 13 + /** Filters to apply to the results */ 14 + filters?: StructuredFilters 11 15 /** Search query for highlighting exact matches */ 12 16 searchQuery?: string 17 + }>() 18 + 19 + const emit = defineEmits<{ 20 + clickKeyword: [keyword: string] 13 21 }>() 14 22 15 23 /** Check if this package is an exact match for the search query */ ··· 149 157 </div> 150 158 </div> 151 159 152 - <ul 160 + <div 153 161 v-if="result.package.keywords?.length" 154 162 :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" 156 164 > 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 + > 158 174 {{ 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> 161 184 </BaseCard> 162 185 </template>
+16 -1
app/components/Package/List.vue
··· 17 17 const props = defineProps<{ 18 18 /** List of search results to display */ 19 19 results: NpmSearchResult[] 20 + /** Filters to apply to the results */ 21 + filters?: StructuredFilters 20 22 /** Heading level for package names */ 21 23 headingLevel?: 'h2' | 'h3' 22 24 /** Whether to show publisher username on cards */ ··· 39 41 paginationMode?: PaginationMode 40 42 /** Current page (1-indexed) for paginated mode */ 41 43 currentPage?: number 44 + /** When true, shows search-specific UI (relevance sort, no filters) */ 45 + searchContext?: boolean 42 46 }>() 43 47 44 48 const emit = defineEmits<{ ··· 60 64 61 65 // View mode and columns 62 66 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 + }) 64 72 // Table view forces pagination mode (no virtualization for tables) 65 73 const paginationMode = computed(() => 66 74 viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'), ··· 147 155 <template v-if="viewMode === 'table'"> 148 156 <PackageTable 149 157 :results="displayedResults" 158 + :filters="filters" 150 159 :columns="columns" 151 160 v-model:sort-option="sortOption" 152 161 :is-loading="isLoading" ··· 176 185 :index="index" 177 186 :search-query="searchQuery" 178 187 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 188 + :filters="filters" 179 189 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 190 + @click-keyword="emit('clickKeyword', $event)" 180 191 /> 181 192 </div> 182 193 </template> ··· 193 204 :show-publisher="showPublisher" 194 205 :index="index" 195 206 :search-query="searchQuery" 207 + :filters="filters" 208 + @click-keyword="emit('clickKeyword', $event)" 196 209 /> 197 210 </div> 198 211 </li> ··· 225 238 :search-query="searchQuery" 226 239 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 227 240 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 241 + :filters="filters" 242 + @click-keyword="emit('clickKeyword', $event)" 228 243 /> 229 244 </li> 230 245 </ol>
+9 -1
app/components/Package/Table.vue
··· 1 1 <script setup lang="ts"> 2 2 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' 4 10 import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types/preferences' 5 11 6 12 const props = defineProps<{ 7 13 results: NpmSearchResult[] 8 14 columns: ColumnConfig[] 15 + filters?: StructuredFilters 9 16 isLoading?: boolean 10 17 }>() 11 18 ··· 317 324 :result="result" 318 325 :columns="columns" 319 326 :index="index" 327 + :filters="filters" 320 328 @click-keyword="emit('clickKeyword', $event)" 321 329 /> 322 330 </template>
+14 -4
app/components/Package/TableRow.vue
··· 1 1 <script setup lang="ts"> 2 2 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' 4 4 5 5 const props = defineProps<{ 6 6 result: NpmSearchResult 7 7 columns: ColumnConfig[] 8 8 index?: number 9 + filters?: StructuredFilters 9 10 }>() 10 11 11 12 const emit = defineEmits<{ ··· 117 118 118 119 <!-- Keywords --> 119 120 <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 + > 121 126 <button 122 127 v-for="keyword in pkg.keywords.slice(0, 3)" 123 128 :key="keyword" 124 129 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) }" 126 132 :title="`Filter by ${keyword}`" 127 133 @click.stop="emit('clickKeyword', keyword)" 128 134 > 129 135 {{ keyword }} 130 136 </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 + > 132 142 +{{ pkg.keywords.length - 3 }} 133 143 </span> 134 144 </div>
+19
app/composables/useStructuredFilters.ts
··· 112 112 * 113 113 */ 114 114 export function useStructuredFilters(options: UseStructuredFiltersOptions) { 115 + const route = useRoute() 116 + const router = useRouter() 115 117 const { packages, initialFilters, initialSort } = options 116 118 const { t } = useI18n() 119 + 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 + ) 117 130 118 131 // Filter state 119 132 const filters = ref<StructuredFilters>({ ··· 387 400 function addKeyword(keyword: string) { 388 401 if (!filters.value.keywords.includes(keyword)) { 389 402 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 } }) 390 407 } 391 408 } 392 409 393 410 function removeKeyword(keyword: string) { 394 411 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 } }) 395 414 } 396 415 397 416 function toggleKeyword(keyword: string) {
+11 -4
app/pages/@[org].vue
··· 52 52 } = useStructuredFilters({ 53 53 packages, 54 54 initialFilters: { 55 - text: normalizeSearchParam(route.query.q), 55 + ...parseSearchOperators(normalizeSearchParam(route.query.q)), 56 56 }, 57 57 initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc', 58 58 }) ··· 91 91 }, 300) 92 92 93 93 // 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 + ) 97 103 98 104 const filteredCount = computed(() => sortedPackages.value.length) 99 105 ··· 282 288 :results="sortedPackages" 283 289 :view-mode="viewMode" 284 290 :columns="columns" 291 + :filters="filters" 285 292 v-model:sort-option="sortOption" 286 293 :pagination-mode="paginationMode" 287 294 :page-size="pageSize"
+5
app/pages/search.vue
··· 142 142 clearAllFilters, 143 143 } = useStructuredFilters({ 144 144 packages: resultsArray, 145 + initialFilters: { 146 + ...parseSearchOperators(normalizeSearchParam(route.query.q)), 147 + }, 145 148 initialSort: 'relevance-desc', // Default to search relevance 146 149 }) 147 150 ··· 737 740 v-if="displayResults.length > 0" 738 741 :results="displayResults" 739 742 :search-query="query" 743 + :filters="filters" 744 + search-context 740 745 heading-level="h2" 741 746 show-publisher 742 747 :has-more="hasMore"