Skip to content
Merged
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
30 changes: 28 additions & 2 deletions app/components/PackageCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
defineProps<{
const props = defineProps<{
/** The search result object containing package data */
result: NpmSearchResult
/** Heading level for the package name (h2 for search, h3 for lists) */
Expand All @@ -9,8 +9,18 @@ defineProps<{
prefetch?: boolean
selected?: boolean
index?: number
/** Search query for highlighting exact matches */
searchQuery?: string
}>()

/** Check if this package is an exact match for the search query */
const isExactMatch = computed(() => {
if (!props.searchQuery) return false
const query = props.searchQuery.trim().toLowerCase()
const name = props.result.package.name.toLowerCase()
return query === name
})

const emit = defineEmits<{
focus: [index: number]
}>()
Expand All @@ -19,8 +29,17 @@ const emit = defineEmits<{
<template>
<article
class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50"
:class="{ 'bg-bg-muted border-border-hover': selected }"
:class="{
'bg-bg-muted border-border-hover': selected,
'border-accent/30 bg-accent/5': isExactMatch,
}"
>
<!-- Glow effect for exact matches -->
<div
v-if="isExactMatch"
class="absolute -inset-px rounded-lg bg-gradient-to-r from-accent/0 via-accent/20 to-accent/0 opacity-100 blur-sm -z-1 pointer-events-none motion-reduce:opacity-50"
aria-hidden="true"
/>
<div class="mb-2 flex items-baseline justify-between gap-2">
<component
:is="headingLevel ?? 'h3'"
Expand All @@ -37,6 +56,13 @@ const emit = defineEmits<{
{{ result.package.name }}
</NuxtLink>
</component>
<!-- Exact match badge -->
<span
v-if="isExactMatch"
class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-accent/20 border border-accent/30 text-accent font-mono"
>
{{ $t('search.exact_match') }}
</span>
<!-- Mobile: version next to package name -->
<div class="sm:hidden text-fg-subtle flex items-center gap-1.5 shrink-0">
<span
Expand Down
3 changes: 3 additions & 0 deletions app/components/PackageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const props = defineProps<{
initialPage?: number
/** Selected result index (for keyboard navigation) */
selectedIndex?: number
/** Search query for highlighting exact matches */
searchQuery?: string
}>()

const emit = defineEmits<{
Expand Down Expand Up @@ -111,6 +113,7 @@ defineExpose({
:show-publisher="showPublisher"
:selected="index === (selectedIndex ?? -1)"
:index="index"
:search-query="searchQuery"
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@focus="emit('select', $event)"
Expand Down
85 changes: 85 additions & 0 deletions app/components/SearchSuggestionCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
defineProps<{
/** Type of suggestion: 'user' or 'org' */
type: 'user' | 'org'
/** The name (username or org name) */
name: string
/** Whether this suggestion is currently selected (keyboard nav) */
selected?: boolean
/** Whether this is an exact match for the query */
isExactMatch?: boolean
/** Index for keyboard navigation */
index?: number
}>()

const emit = defineEmits<{
focus: [index: number]
}>()
</script>

<template>
<article
class="group card-interactive relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50"
:class="{
'bg-bg-muted border-border-hover': selected,
'border-accent/30 bg-accent/5': isExactMatch,
}"
>
<!-- Glow effect for exact matches -->
<div
v-if="isExactMatch"
class="absolute -inset-px rounded-lg bg-gradient-to-r from-accent/0 via-accent/20 to-accent/0 opacity-100 blur-sm -z-1 pointer-events-none motion-reduce:opacity-50"
aria-hidden="true"
/>
<NuxtLink
:to="type === 'user' ? `/~${name}` : `/@${name}`"
:data-suggestion-index="index"
class="flex items-center gap-4 focus-visible:outline-none after:content-[''] after:absolute after:inset-0"
@focus="index != null && emit('focus', index)"
@mouseenter="index != null && emit('focus', index)"
>
<!-- Avatar placeholder -->
<div
class="w-10 h-10 shrink-0 flex items-center justify-center border border-border"
:class="type === 'org' ? 'rounded-lg bg-bg-muted' : 'rounded-full bg-bg-muted'"
aria-hidden="true"
>
<span class="text-lg text-fg-subtle font-mono">{{ name.charAt(0).toUpperCase() }}</span>
</div>

<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors"
>
{{ type === 'user' ? '~' : '@' }}{{ name }}
</span>
<span
class="text-xs px-1.5 py-0.5 rounded bg-bg-muted border border-border text-fg-muted font-mono"
>
{{ type === 'user' ? $t('search.suggestion.user') : $t('search.suggestion.org') }}
</span>
<!-- Exact match badge -->
<span
v-if="isExactMatch"
class="text-xs px-1.5 py-0.5 rounded bg-accent/20 border border-accent/30 text-accent font-mono"
>
{{ $t('search.exact_match') }}
</span>
</div>
<p class="text-xs sm:text-sm text-fg-muted mt-0.5">
{{
type === 'user'
? $t('search.suggestion.view_user_packages')
: $t('search.suggestion.view_org_packages')
}}
</p>
</div>

<span
class="i-carbon-arrow-right w-4 h-4 text-fg-subtle group-hover:text-fg transition-colors shrink-0"
aria-hidden="true"
/>
</NuxtLink>
</article>
</template>
Loading