forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { StructuredFilters } from '#shared/types/preferences'
3
4const props = defineProps<{
5 /** The search result object containing package data */
6 result: NpmSearchResult
7 /** Heading level for the package name (h2 for search, h3 for lists) */
8 headingLevel?: 'h2' | 'h3'
9 /** Whether to show the publisher username */
10 showPublisher?: boolean
11 prefetch?: boolean
12 index?: number
13 /** Filters to apply to the results */
14 filters?: StructuredFilters
15 /** Search query for highlighting exact matches */
16 searchQuery?: string
17}>()
18
19const emit = defineEmits<{
20 clickKeyword: [keyword: string]
21}>()
22
23/** Check if this package is an exact match for the search query */
24const isExactMatch = computed(() => {
25 if (!props.searchQuery) return false
26 const query = props.searchQuery.trim().toLowerCase()
27 const name = props.result.package.name.toLowerCase()
28 return query === name
29})
30
31// Process package description
32const pkgDescription = useMarkdown(() => ({
33 text: props.result.package.description ?? '',
34 plain: true,
35 packageName: props.result.package.name,
36}))
37
38const numberFormatter = useNumberFormatter()
39</script>
40
41<template>
42 <BaseCard :isExactMatch="isExactMatch">
43 <div class="mb-2 flex items-baseline justify-start gap-2">
44 <component
45 :is="headingLevel ?? 'h3'"
46 class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
47 >
48 <NuxtLink
49 :to="packageRoute(result.package.name)"
50 :prefetch-on="prefetch ? 'visibility' : 'interaction'"
51 class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
52 :data-result-index="index"
53 dir="ltr"
54 >{{ result.package.name }}</NuxtLink
55 >
56 <span
57 v-if="isExactMatch"
58 class="text-xs px-1.5 py-0.5 ms-2 rounded bg-bg-elevated border border-border-hover text-fg"
59 >{{ $t('search.exact_match') }}</span
60 >
61 </component>
62 <span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
63 <!-- Mobile: version next to package name -->
64 <div class="sm:hidden text-fg-subtle flex items-center gap-1.5 shrink-0">
65 <span
66 v-if="result.package.version"
67 class="font-mono text-xs truncate max-w-20"
68 :title="result.package.version"
69 >
70 v{{ result.package.version }}
71 </span>
72 <ProvenanceBadge
73 v-if="result.package.publisher?.trustedPublisher"
74 :provider="result.package.publisher.trustedPublisher.id"
75 :package-name="result.package.name"
76 :version="result.package.version"
77 :linked="false"
78 compact
79 />
80 </div>
81 </div>
82 <div class="flex justify-start items-start gap-4 sm:gap-8">
83 <div class="min-w-0">
84 <p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
85 <span v-html="pkgDescription" />
86 </p>
87 <div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
88 <dl v-if="showPublisher || result.package.date" class="flex items-center gap-4 m-0">
89 <div
90 v-if="showPublisher && result.package.publisher?.username"
91 class="flex items-center gap-1.5"
92 >
93 <dt class="sr-only">{{ $t('package.card.publisher') }}</dt>
94 <dd class="font-mono">{{ result.package.publisher.username }}</dd>
95 </div>
96 <div v-if="result.package.date" class="flex items-center gap-1.5">
97 <dt class="sr-only">{{ $t('package.card.published') }}</dt>
98 <dd>
99 <DateTime
100 :datetime="result.package.date"
101 year="numeric"
102 month="short"
103 day="numeric"
104 />
105 </dd>
106 </div>
107 <div v-if="result.package.license" class="flex items-center gap-1.5">
108 <dt class="sr-only">{{ $t('package.card.license') }}</dt>
109 <dd>{{ result.package.license }}</dd>
110 </div>
111 </dl>
112 </div>
113 <!-- Mobile: downloads on separate row -->
114 <dl
115 v-if="result.downloads?.weekly"
116 class="sm:hidden flex items-center gap-4 mt-2 text-xs text-fg-muted m-0"
117 >
118 <div class="flex items-center gap-1.5">
119 <dt class="sr-only">{{ $t('package.card.weekly_downloads') }}</dt>
120 <dd class="flex items-center gap-1.5">
121 <span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
122 <span class="font-mono">{{ $n(result.downloads.weekly) }}/w</span>
123 </dd>
124 </div>
125 </dl>
126 </div>
127 <span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
128 <!-- Desktop: version and downloads on right side -->
129 <div class="hidden sm:flex flex-col gap-2 shrink-0">
130 <div class="text-fg-subtle flex items-start gap-2 justify-end">
131 <span
132 v-if="result.package.version"
133 class="font-mono text-xs truncate max-w-32"
134 :title="result.package.version"
135 >
136 v{{ result.package.version }}
137 </span>
138 <div
139 v-if="result.package.publisher?.trustedPublisher"
140 class="flex items-center gap-1.5 shrink-0 max-w-32"
141 >
142 <ProvenanceBadge
143 :provider="result.package.publisher.trustedPublisher.id"
144 :package-name="result.package.name"
145 :version="result.package.version"
146 :linked="false"
147 compact
148 />
149 </div>
150 </div>
151 <div
152 v-if="result.downloads?.weekly"
153 class="text-fg-subtle gap-2 flex items-center justify-end"
154 >
155 <span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
156 <span class="font-mono text-xs">
157 {{ $n(result.downloads.weekly) }} {{ $t('common.per_week') }}
158 </span>
159 </div>
160 </div>
161 </div>
162
163 <ul
164 role="list"
165 v-if="result.package.keywords?.length"
166 :aria-label="$t('package.card.keywords')"
167 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 items-center"
168 >
169 <li v-for="keyword in result.package.keywords.slice(0, 5)" :key="keyword">
170 <ButtonBase
171 class="pointer-events-auto"
172 size="small"
173 :aria-pressed="props.filters?.keywords.includes(keyword)"
174 :title="`Filter by ${keyword}`"
175 :data-result-index="index"
176 @click.stop="emit('clickKeyword', keyword)"
177 >
178 {{ keyword }}
179 </ButtonBase>
180 </li>
181 <li>
182 <span
183 v-if="result.package.keywords.length > 5"
184 class="text-fg-subtle text-xs pointer-events-auto"
185 :title="result.package.keywords.slice(5).join(', ')"
186 >
187 +{{ numberFormatter.format(result.package.keywords.length - 5) }}
188 </span>
189 </li>
190 </ul>
191 </BaseCard>
192</template>