Skip to content

Commit e2e8b5f

Browse files
ShroXdghostdevv
andcommitted
fix(ui): improve search page UX (#2115)
Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 0e3c850 commit e2e8b5f

File tree

3 files changed

+89
-42
lines changed

3 files changed

+89
-42
lines changed

app/components/Package/List.vue

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ watch(
112112
{ immediate: true },
113113
)
114114
115+
// Tracks how many items came from the last new-search batch.
116+
// Items at index < newSearchBatchSize are from the new search → no animation.
117+
// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger.
118+
// Using an index threshold avoids any timing dependency on nextTick / virtual list paint.
119+
const newSearchBatchSize = shallowRef(Infinity)
120+
115121
// Reset scroll state when results change significantly (new search)
116122
watch(
117123
() => props.results,
@@ -123,6 +129,7 @@ watch(
123129
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
124130
) {
125131
hasScrolledToInitial.value = false
132+
newSearchBatchSize.value = newResults.length
126133
}
127134
},
128135
)
@@ -172,9 +179,16 @@ defineExpose({
172179
:show-publisher="showPublisher"
173180
:index="index"
174181
:search-query="searchQuery"
175-
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
182+
:class="
183+
index >= newSearchBatchSize &&
184+
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
185+
"
186+
:style="
187+
index >= newSearchBatchSize
188+
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
189+
: {}
190+
"
176191
:filters="filters"
177-
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
178192
@click-keyword="emit('clickKeyword', $event)"
179193
/>
180194
</div>
@@ -224,8 +238,15 @@ defineExpose({
224238
:show-publisher="showPublisher"
225239
:index="index"
226240
:search-query="searchQuery"
227-
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
228-
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
241+
:class="
242+
index >= newSearchBatchSize &&
243+
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
244+
"
245+
:style="
246+
index >= newSearchBatchSize
247+
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
248+
: {}
249+
"
229250
:filters="filters"
230251
@click-keyword="emit('clickKeyword', $event)"
231252
/>

app/composables/useGlobalSearch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { debounce } from 'perfect-debounce'
44
// Pages that have their own local filter using ?q
55
const pagesWithLocalFilter = new Set(['~username', 'org'])
66

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

32+
const commitSearchQuery = debounce((val: string) => {
33+
committedSearchQuery.value = val
34+
}, SEARCH_DEBOUNCE_MS)
35+
3036
// This is basically doing instant search as user types
3137
watch(searchQuery, val => {
3238
if (settings.value.instantSearch) {
33-
committedSearchQuery.value = val
39+
commitSearchQuery(val)
3440
}
3541
})
3642

@@ -71,10 +77,11 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
7177
})
7278
}
7379

74-
const updateUrlQuery = debounce(updateUrlQueryImpl, 250)
80+
const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS)
7581

7682
function flushUpdateUrlQuery() {
7783
// Commit the current query when explicitly submitted (Enter pressed)
84+
commitSearchQuery.cancel()
7885
committedSearchQuery.value = searchQuery.value
7986
// When instant search is off the debounce queue is empty, so call directly
8087
if (!settings.value.instantSearch) {

app/pages/search.vue

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,19 @@ const canPublishToScope = computed(() => {
350350
351351
// Show claim prompt when valid name, available, either not connected or connected and has permission
352352
const showClaimPrompt = computed(() => {
353-
return (
354-
isValidPackageName.value &&
355-
packageAvailability.value?.available === true &&
356-
packageAvailability.value.name === query.value.trim() &&
357-
(!isConnected.value || (isConnected.value && canPublishToScope.value)) &&
358-
status.value !== 'pending'
359-
)
353+
if (!isValidPackageName.value) return false
354+
if (isConnected.value && !canPublishToScope.value) return false
355+
356+
const avail = packageAvailability.value
357+
358+
// Confirmed: availability result matches current committed query
359+
if (avail?.available === true && avail.name === committedQuery.value.trim()) return true
360+
361+
// Pending: a new fetch is in flight — keep the claim visible if the last known
362+
// result was "available" so it doesn't flicker until new data arrives
363+
if (status.value === 'pending' && avail?.available === true) return true
364+
365+
return false
360366
})
361367
362368
const claimPackageModalRef = useTemplateRef('claimPackageModalRef')
@@ -707,22 +713,28 @@ onBeforeUnmount(() => {
707713
status === 'success'
708714
"
709715
>
710-
<div
711-
v-if="validatedSuggestions.length > 0 && displayResults.length > 0"
712-
class="mb-6 space-y-3"
716+
<Transition
717+
enter-active-class="motion-safe:animate-slide-up motion-safe:animate-fill-both"
718+
leave-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
719+
leave-to-class="opacity-0 motion-safe:-translate-y-1.5"
713720
>
714-
<SearchSuggestionCard
715-
v-for="(suggestion, idx) in validatedSuggestions"
716-
:key="`${suggestion.type}-${suggestion.name}`"
717-
:type="suggestion.type"
718-
:name="suggestion.name"
719-
:index="idx"
720-
:is-exact-match="
721-
(exactMatchType === 'org' && suggestion.type === 'org') ||
722-
(exactMatchType === 'user' && suggestion.type === 'user')
723-
"
724-
/>
725-
</div>
721+
<div
722+
v-if="validatedSuggestions.length > 0 && displayResults.length > 0"
723+
class="mb-6 space-y-3"
724+
>
725+
<SearchSuggestionCard
726+
v-for="(suggestion, idx) in validatedSuggestions"
727+
:key="`${suggestion.type}-${suggestion.name}`"
728+
:type="suggestion.type"
729+
:name="suggestion.name"
730+
:index="idx"
731+
:is-exact-match="
732+
(exactMatchType === 'org' && suggestion.type === 'org') ||
733+
(exactMatchType === 'user' && suggestion.type === 'user')
734+
"
735+
/>
736+
</div>
737+
</Transition>
726738

727739
<div
728740
v-if="showClaimPrompt && visibleResults && displayResults.length > 0"
@@ -736,7 +748,8 @@ onBeforeUnmount(() => {
736748
</div>
737749
<button
738750
type="button"
739-
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"
751+
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"
752+
:disabled="status === 'pending'"
740753
@click="claimPackageModalRef?.open()"
741754
>
742755
{{ $t('search.claim_button', { name: query }) }}
@@ -819,19 +832,25 @@ onBeforeUnmount(() => {
819832
{{ $t('search.no_results', { query }) }}
820833
</p>
821834

822-
<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
823-
<SearchSuggestionCard
824-
v-for="(suggestion, idx) in validatedSuggestions"
825-
:key="`${suggestion.type}-${suggestion.name}`"
826-
:type="suggestion.type"
827-
:name="suggestion.name"
828-
:index="idx"
829-
:is-exact-match="
830-
(exactMatchType === 'org' && suggestion.type === 'org') ||
831-
(exactMatchType === 'user' && suggestion.type === 'user')
832-
"
833-
/>
834-
</div>
835+
<Transition
836+
enter-active-class="motion-safe:animate-slide-up motion-safe:animate-fill-both"
837+
leave-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
838+
leave-to-class="opacity-0 motion-safe:-translate-y-1.5"
839+
>
840+
<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
841+
<SearchSuggestionCard
842+
v-for="(suggestion, idx) in validatedSuggestions"
843+
:key="`${suggestion.type}-${suggestion.name}`"
844+
:type="suggestion.type"
845+
:name="suggestion.name"
846+
:index="idx"
847+
:is-exact-match="
848+
(exactMatchType === 'org' && suggestion.type === 'org') ||
849+
(exactMatchType === 'user' && suggestion.type === 'user')
850+
"
851+
/>
852+
</div>
853+
</Transition>
835854

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

0 commit comments

Comments
 (0)