Skip to content
Open
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
29 changes: 25 additions & 4 deletions app/components/Package/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ watch(
{ immediate: true },
)

// Tracks how many items came from the last new-search batch.
// Items at index < newSearchBatchSize are from the new search → no animation.
// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger.
// Using an index threshold avoids any timing dependency on nextTick / virtual list paint.
const newSearchBatchSize = shallowRef(Infinity)

// Reset scroll state when results change significantly (new search)
watch(
() => props.results,
Expand All @@ -123,6 +129,7 @@ watch(
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
) {
hasScrolledToInitial.value = false
newSearchBatchSize.value = newResults.length
}
},
)
Expand Down Expand Up @@ -172,9 +179,16 @@ defineExpose({
:show-publisher="showPublisher"
:index="index"
:search-query="searchQuery"
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
:class="
index >= newSearchBatchSize &&
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
"
:style="
index >= newSearchBatchSize
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
: {}
"
:filters="filters"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@click-keyword="emit('clickKeyword', $event)"
/>
</div>
Expand Down Expand Up @@ -224,8 +238,15 @@ defineExpose({
:show-publisher="showPublisher"
: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` }"
:class="
index >= newSearchBatchSize &&
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
"
:style="
index >= newSearchBatchSize
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
: {}
"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
Expand Down
11 changes: 9 additions & 2 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { debounce } from 'perfect-debounce'
// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

const SEARCH_DEBOUNCE_MS = 250

export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
Expand All @@ -27,10 +29,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
// Syncs instantly when instantSearch is on, but only on Enter press when off
const committedSearchQuery = useState<string>('committed-search-query', () => searchQuery.value)

const commitSearchQuery = debounce((val: string) => {
committedSearchQuery.value = val
}, SEARCH_DEBOUNCE_MS)

// This is basically doing instant search as user types
watch(searchQuery, val => {
if (settings.value.instantSearch) {
committedSearchQuery.value = val
commitSearchQuery(val)
}
})

Expand Down Expand Up @@ -71,10 +77,11 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
})
}

const updateUrlQuery = debounce(updateUrlQueryImpl, 250)
const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS)

function flushUpdateUrlQuery() {
// Commit the current query when explicitly submitted (Enter pressed)
commitSearchQuery.cancel()
committedSearchQuery.value = searchQuery.value
// When instant search is off the debounce queue is empty, so call directly
if (!settings.value.instantSearch) {
Expand Down
91 changes: 55 additions & 36 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,19 @@ const canPublishToScope = computed(() => {

// Show claim prompt when valid name, available, either not connected or connected and has permission
const showClaimPrompt = computed(() => {
return (
isValidPackageName.value &&
packageAvailability.value?.available === true &&
packageAvailability.value.name === query.value.trim() &&
(!isConnected.value || (isConnected.value && canPublishToScope.value)) &&
status.value !== 'pending'
)
if (!isValidPackageName.value) return false
if (isConnected.value && !canPublishToScope.value) return false

const avail = packageAvailability.value

// Confirmed: availability result matches current committed query
if (avail?.available === true && avail.name === committedQuery.value.trim()) return true

// Pending: a new fetch is in flight — keep the claim visible if the last known
// result was "available" so it doesn't flicker until new data arrives
if (status.value === 'pending' && avail?.available === true) return true

return false
})

const claimPackageModalRef = useTemplateRef('claimPackageModalRef')
Expand Down Expand Up @@ -711,22 +717,28 @@ onBeforeUnmount(() => {
status === 'success'
"
>
<div
v-if="validatedSuggestions.length > 0 && displayResults.length > 0"
class="mb-6 space-y-3"
<Transition
enter-active-class="motion-safe:animate-slide-up motion-safe:animate-fill-both"
leave-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
leave-to-class="opacity-0 motion-safe:-translate-y-1.5"
>
<SearchSuggestionCard
v-for="(suggestion, idx) in validatedSuggestions"
:key="`${suggestion.type}-${suggestion.name}`"
:type="suggestion.type"
:name="suggestion.name"
:index="idx"
:is-exact-match="
(exactMatchType === 'org' && suggestion.type === 'org') ||
(exactMatchType === 'user' && suggestion.type === 'user')
"
/>
</div>
<div
v-if="validatedSuggestions.length > 0 && displayResults.length > 0"
class="mb-6 space-y-3"
>
<SearchSuggestionCard
v-for="(suggestion, idx) in validatedSuggestions"
:key="`${suggestion.type}-${suggestion.name}`"
:type="suggestion.type"
:name="suggestion.name"
:index="idx"
:is-exact-match="
(exactMatchType === 'org' && suggestion.type === 'org') ||
(exactMatchType === 'user' && suggestion.type === 'user')
"
/>
</div>
</Transition>

<div
v-if="showClaimPrompt && visibleResults && displayResults.length > 0"
Expand All @@ -740,7 +752,8 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="shrink-0 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md motion-safe:transition-colors motion-safe:duration-200 hover:bg-fg/90 focus-visible:outline-accent/70"
class="shrink-0 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md motion-safe:transition-[color,background-color,opacity] motion-safe:duration-200 hover:bg-fg/90 focus-visible:outline-accent/70 disabled:opacity-85 disabled:cursor-not-allowed"
:disabled="status === 'pending'"
@click="claimPackageModalRef?.open()"
>
{{ $t('search.claim_button', { name: query }) }}
Expand Down Expand Up @@ -823,19 +836,25 @@ onBeforeUnmount(() => {
{{ $t('search.no_results', { query }) }}
</p>

<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
<SearchSuggestionCard
v-for="(suggestion, idx) in validatedSuggestions"
:key="`${suggestion.type}-${suggestion.name}`"
:type="suggestion.type"
:name="suggestion.name"
:index="idx"
:is-exact-match="
(exactMatchType === 'org' && suggestion.type === 'org') ||
(exactMatchType === 'user' && suggestion.type === 'user')
"
/>
</div>
<Transition
enter-active-class="motion-safe:animate-slide-up motion-safe:animate-fill-both"
leave-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
leave-to-class="opacity-0 motion-safe:-translate-y-1.5"
>
<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
<SearchSuggestionCard
v-for="(suggestion, idx) in validatedSuggestions"
:key="`${suggestion.type}-${suggestion.name}`"
:type="suggestion.type"
:name="suggestion.name"
:index="idx"
:is-exact-match="
(exactMatchType === 'org' && suggestion.type === 'org') ||
(exactMatchType === 'user' && suggestion.type === 'user')
"
/>
</div>
</Transition>

<div v-if="showClaimPrompt" class="max-w-md mx-auto text-center hidden sm:block">
<div class="p-4 bg-bg-subtle border border-border rounded-lg">
Expand Down
Loading