Skip to content
15 changes: 14 additions & 1 deletion app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ function handleSearchFocus() {
emit('focus')
}

function handleSubmit() {
if (pagesWithLocalFilter.has(route.name as string)) {
router.push({
name: 'search',
query: {
q: searchQuery.value,
},
})
} else {
updateUrlQuery.flush()
}
}

// Expose focus method for parent components
const inputRef = shallowRef<HTMLInputElement | null>(null)
function focus() {
Expand All @@ -88,7 +101,7 @@ defineExpose({ focus })
</script>
<template>
<search v-if="showSearchBar" :class="'flex-1 sm:max-w-md ' + inputClass">
<form method="GET" action="/search" class="relative">
<form method="GET" action="/search" class="relative" @submit.prevent="handleSubmit">
<label for="header-search" class="sr-only">
{{ $t('search.label') }}
</label>
Expand Down
33 changes: 28 additions & 5 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { StructuredFilters } from '#shared/types/preferences'

const props = defineProps<{
/** The search result object containing package data */
result: NpmSearchResult
Expand All @@ -8,10 +10,16 @@ const props = defineProps<{
showPublisher?: boolean
prefetch?: boolean
index?: number
/** Filters to apply to the results */
filters?: StructuredFilters
/** Search query for highlighting exact matches */
searchQuery?: string
}>()

const emit = defineEmits<{
clickKeyword: [keyword: string]
}>()

/** Check if this package is an exact match for the search query */
const isExactMatch = computed(() => {
if (!props.searchQuery) return false
Expand Down Expand Up @@ -149,14 +157,29 @@ const pkgDescription = useMarkdown(() => ({
</div>
</div>

<ul
<div
v-if="result.package.keywords?.length"
:aria-label="$t('package.card.keywords')"
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0"
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"
>
<li v-for="keyword in result.package.keywords.slice(0, 5)" :key="keyword" class="tag">
<button
v-for="keyword in result.package.keywords.slice(0, 5)"
:key="keyword"
type="button"
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"
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
:title="`Filter by ${keyword}`"
@click.stop="emit('clickKeyword', keyword)"
>
{{ keyword }}
</li>
</ul>
</button>
<span
v-if="result.package.keywords.length > 5"
class="tag text-fg-subtle text-xs border-none bg-transparent pointer-events-auto"
:title="result.package.keywords.slice(5).join(', ')"
>
+{{ result.package.keywords.length - 5 }}
</span>
</div>
</BaseCard>
</template>
17 changes: 16 additions & 1 deletion app/components/Package/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const SSR_COUNT = 20
const props = defineProps<{
/** List of search results to display */
results: NpmSearchResult[]
/** Filters to apply to the results */
filters?: StructuredFilters
/** Heading level for package names */
headingLevel?: 'h2' | 'h3'
/** Whether to show publisher username on cards */
Expand All @@ -39,6 +41,8 @@ const props = defineProps<{
paginationMode?: PaginationMode
/** Current page (1-indexed) for paginated mode */
currentPage?: number
/** When true, shows search-specific UI (relevance sort, no filters) */
searchContext?: boolean
}>()

const emit = defineEmits<{
Expand All @@ -60,7 +64,11 @@ const sortOption = defineModel<SortOption>('sortOption')

// View mode and columns
const viewMode = computed(() => props.viewMode ?? 'cards')
const columns = computed(() => props.columns ?? DEFAULT_COLUMNS)
const columns = computed(() => {
const targetColumns = props.columns ?? DEFAULT_COLUMNS
if (props.searchContext) return targetColumns.map(column => ({ ...column, sortable: false }))
return targetColumns
})
// Table view forces pagination mode (no virtualization for tables)
const paginationMode = computed(() =>
viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'),
Expand Down Expand Up @@ -147,6 +155,7 @@ defineExpose({
<template v-if="viewMode === 'table'">
<PackageTable
:results="displayedResults"
:filters="filters"
:columns="columns"
v-model:sort-option="sortOption"
:is-loading="isLoading"
Expand Down Expand Up @@ -176,7 +185,9 @@ defineExpose({
:index="index"
:search-query="searchQuery"
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
:filters="filters"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@click-keyword="emit('clickKeyword', $event)"
/>
</div>
</template>
Expand All @@ -193,6 +204,8 @@ defineExpose({
:show-publisher="showPublisher"
:index="index"
:search-query="searchQuery"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
</div>
</li>
Expand Down Expand Up @@ -225,6 +238,8 @@ defineExpose({
:search-query="searchQuery"
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
</li>
</ol>
Expand Down
10 changes: 9 additions & 1 deletion app/components/Package/Table.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<script setup lang="ts">
import type { NpmSearchResult } from '#shared/types/npm-registry'
import type { ColumnConfig, ColumnId, SortKey, SortOption } from '#shared/types/preferences'
import type {
ColumnConfig,
ColumnId,
SortKey,
SortOption,
StructuredFilters,
} from '#shared/types/preferences'
import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types/preferences'

const props = defineProps<{
results: NpmSearchResult[]
columns: ColumnConfig[]
filters?: StructuredFilters
isLoading?: boolean
}>()

Expand Down Expand Up @@ -317,6 +324,7 @@ function getColumnLabelKey(id: ColumnId): string {
:result="result"
:columns="columns"
:index="index"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
</template>
Expand Down
18 changes: 14 additions & 4 deletions app/components/Package/TableRow.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { NpmSearchResult } from '#shared/types/npm-registry'
import type { ColumnConfig } from '#shared/types/preferences'
import type { ColumnConfig, StructuredFilters } from '#shared/types/preferences'

const props = defineProps<{
result: NpmSearchResult
columns: ColumnConfig[]
index?: number
filters?: StructuredFilters
}>()

const emit = defineEmits<{
Expand Down Expand Up @@ -117,18 +118,27 @@ const allMaintainersText = computed(() => {

<!-- Keywords -->
<td v-if="isColumnVisible('keywords')" class="py-2 px-3">
<div v-if="pkg.keywords?.length" class="flex flex-wrap gap-1">
<div
v-if="pkg.keywords?.length"
class="flex flex-wrap gap-1"
:aria-label="$t('package.card.keywords')"
>
<button
v-for="keyword in pkg.keywords.slice(0, 3)"
:key="keyword"
type="button"
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"
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"
:class="{ 'bg-fg text-bg hover:opacity-80': props.filters?.keywords.includes(keyword) }"
:title="`Filter by ${keyword}`"
@click.stop="emit('clickKeyword', keyword)"
>
{{ keyword }}
</button>
<span v-if="pkg.keywords.length > 3" class="text-fg-subtle text-xs">
<span
v-if="pkg.keywords.length > 3"
class="tag text-fg-subtle text-xs border-none bg-transparent"
:title="pkg.keywords.slice(3).join(', ')"
>
+{{ pkg.keywords.length - 3 }}
</span>
</div>
Expand Down
19 changes: 19 additions & 0 deletions app/composables/useStructuredFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,21 @@ function matchesSecurity(pkg: NpmSearchResult, security: SecurityFilter): boolea
*
*/
export function useStructuredFilters(options: UseStructuredFiltersOptions) {
const route = useRoute()
const router = useRouter()
const { packages, initialFilters, initialSort } = options

const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
watch(
() => route.query.q,
urlQuery => {
const value = normalizeSearchParam(urlQuery)
if (searchQuery.value !== value) {
searchQuery.value = value
}
},
)

// Filter state
const filters = ref<StructuredFilters>({
...DEFAULT_FILTERS,
Expand Down Expand Up @@ -366,11 +379,17 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
function addKeyword(keyword: string) {
if (!filters.value.keywords.includes(keyword)) {
filters.value.keywords = [...filters.value.keywords, keyword]
const newQ = searchQuery.value
? `${searchQuery.value.trim()} keyword:${keyword}`
: `keyword:${keyword}`
router.replace({ query: { ...route.query, q: newQ } })
}
}

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

function toggleKeyword(keyword: string) {
Expand Down
15 changes: 11 additions & 4 deletions app/pages/@[org].vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const {
} = useStructuredFilters({
packages,
initialFilters: {
text: normalizeSearchParam(route.query.q),
...parseSearchOperators(normalizeSearchParam(route.query.q)),
},
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc',
})
Expand Down Expand Up @@ -91,9 +91,15 @@ const updateUrl = debounce((updates: { filter?: string; sort?: string }) => {
}, 300)

// Update URL when filter/sort changes (debounced)
watch([() => filters.value.text, sortOption], ([filter, sort]) => {
updateUrl({ filter, sort })
})
watch(
[() => filters.value.text, () => filters.value.keywords, () => sortOption.value] as const,
([text, keywords, sort]) => {
const filter = [text, ...keywords.map(keyword => `keyword:${keyword}`)]
.filter(Boolean)
.join(' ')
updateUrl({ filter, sort })
},
)

const filteredCount = computed(() => sortedPackages.value.length)

Expand Down Expand Up @@ -282,6 +288,7 @@ defineOgImageComponent('Default', {
:results="sortedPackages"
:view-mode="viewMode"
:columns="columns"
:filters="filters"
v-model:sort-option="sortOption"
:pagination-mode="paginationMode"
:page-size="pageSize"
Expand Down
5 changes: 5 additions & 0 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ const {
clearAllFilters,
} = useStructuredFilters({
packages: resultsArray,
initialFilters: {
...parseSearchOperators(normalizeSearchParam(route.query.q)),
},
initialSort: 'relevance-desc', // Default to search relevance
})
Expand Down Expand Up @@ -737,6 +740,8 @@ defineOgImageComponent('Default', {
v-if="displayResults.length > 0"
:results="displayResults"
:search-query="query"
:filters="filters"
search-context
heading-level="h2"
show-publisher
:has-more="hasMore"
Expand Down
Loading