Skip to content

Commit 4ed4254

Browse files
authored
fix(ui): prevent scroll jump during pagination (#1645)
1 parent 7a4871f commit 4ed4254

File tree

1 file changed

+54
-38
lines changed

1 file changed

+54
-38
lines changed

app/pages/search.vue

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { isPlatformSpecificPackage } from '~/utils/platform-packages'
88
import { normalizeSearchParam } from '#shared/utils/url'
99
1010
const route = useRoute()
11-
const router = useRouter()
1211
1312
// Preferences (persisted to localStorage)
1413
const {
@@ -21,13 +20,16 @@ const {
2120
} = usePackageListPreferences()
2221
2322
// Debounced URL update for page (less aggressive to avoid too many URL changes)
23+
//Use History API directly to update URL without triggering Router's scroll-to-top
2424
const updateUrlPage = debounce((page: number) => {
25-
router.replace({
26-
query: {
27-
...route.query,
28-
page: page > 1 ? page : undefined,
29-
},
30-
})
25+
const url = new URL(window.location.href)
26+
if (page > 1) {
27+
url.searchParams.set('page', page.toString())
28+
} else {
29+
url.searchParams.delete('page')
30+
}
31+
// This updates the address bar "silently"
32+
window.history.replaceState(window.history.state, '', url)
3133
}, 500)
3234
3335
const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
@@ -266,14 +268,18 @@ async function loadMore() {
266268
currentPage.value++
267269
await fetchMore(requestedSize.value)
268270
}
271+
onBeforeUnmount(() => {
272+
updateUrlPage.cancel()
273+
})
269274
270275
// Update URL when page changes from scrolling
271276
function handlePageChange(page: number) {
272277
updateUrlPage(page)
273278
}
274279
275280
// Reset page when query changes
276-
watch(query, () => {
281+
watch(query, (newQuery, oldQuery) => {
282+
if (newQuery.trim() === (oldQuery || '').trim()) return
277283
currentPage.value = 1
278284
hasInteracted.value = true
279285
})
@@ -387,20 +393,24 @@ const totalSelectableCount = computed(() => suggestionCount.value + resultCount.
387393
* Get all focusable result elements in DOM order (suggestions first, then packages)
388394
*/
389395
function getFocusableElements(): HTMLElement[] {
390-
const suggestions = Array.from(
391-
document.querySelectorAll<HTMLElement>('[data-suggestion-index]'),
392-
).sort((a, b) => {
393-
const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10)
394-
const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10)
395-
return aIdx - bIdx
396-
})
397-
const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]')).sort(
398-
(a, b) => {
396+
const isVisible = (el: HTMLElement) => el.getClientRects().length > 0
397+
398+
const suggestions = Array.from(document.querySelectorAll<HTMLElement>('[data-suggestion-index]'))
399+
.filter(isVisible)
400+
.sort((a, b) => {
401+
const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10)
402+
const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10)
403+
return aIdx - bIdx
404+
})
405+
406+
const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]'))
407+
.filter(isVisible)
408+
.sort((a, b) => {
399409
const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10)
400410
const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10)
401411
return aIdx - bIdx
402-
},
403-
)
412+
})
413+
404414
return [...suggestions, ...packages]
405415
}
406416
@@ -533,7 +543,7 @@ defineOgImageComponent('Default', {
533543
</script>
534544

535545
<template>
536-
<main class="flex-1 py-8" :class="{ 'overflow-x-hidden': viewMode !== 'table' }">
546+
<main class="flex-1 py-8 search-page" :class="{ 'overflow-x-hidden': viewMode !== 'table' }">
537547
<div class="container-sm">
538548
<div class="flex items-center justify-between gap-4 mb-4">
539549
<h1 class="font-mono text-2xl sm:text-3xl font-medium">
@@ -542,13 +552,22 @@ defineOgImageComponent('Default', {
542552
<SearchProviderToggle />
543553
</div>
544554

545-
<section v-if="query">
546-
<!-- Initial loading (only after user interaction, not during view transition) -->
555+
<section v-if="query" class="results-layout">
547556
<LoadingSpinner v-if="showSearching" :text="$t('search.searching')" />
548557

549-
<div v-else-if="visibleResults">
550-
<!-- User/Org search suggestions -->
551-
<div v-if="validatedSuggestions.length > 0" class="mb-6 space-y-3">
558+
<div
559+
v-show="
560+
results ||
561+
displayResults.length > 0 ||
562+
isRateLimited ||
563+
status === 'error' ||
564+
status === 'success'
565+
"
566+
>
567+
<div
568+
v-if="validatedSuggestions.length > 0 && displayResults.length > 0"
569+
class="mb-6 space-y-3"
570+
>
552571
<SearchSuggestionCard
553572
v-for="(suggestion, idx) in validatedSuggestions"
554573
:key="`${suggestion.type}-${suggestion.name}`"
@@ -562,9 +581,8 @@ defineOgImageComponent('Default', {
562581
/>
563582
</div>
564583

565-
<!-- Claim prompt - shown at top when valid name but no exact match -->
566584
<div
567-
v-if="showClaimPrompt && visibleResults.total > 0"
585+
v-if="showClaimPrompt && visibleResults && displayResults.length > 0"
568586
class="mb-6 p-4 bg-bg-subtle border border-border rounded-lg sm:flex hidden flex-row sm:items-center gap-3 sm:gap-4"
569587
>
570588
<div class="flex-1 min-w-0">
@@ -582,15 +600,13 @@ defineOgImageComponent('Default', {
582600
</button>
583601
</div>
584602

585-
<!-- Rate limited by npm - check FIRST before showing any results -->
586603
<div v-if="isRateLimited" role="status" class="py-12">
587604
<p class="text-fg-muted font-mono mb-6 text-center">
588605
{{ $t('search.rate_limited') }}
589606
</p>
590607
</div>
591608

592-
<!-- Enhanced toolbar -->
593-
<div v-else-if="visibleResults.total > 0" class="mb-6">
609+
<div v-else-if="visibleResults && displayResults.length > 0" class="mb-6">
594610
<PackageListToolbar
595611
:filters="filters"
596612
v-model:sort-option="sortOption"
@@ -615,7 +631,6 @@ defineOgImageComponent('Default', {
615631
@update:updated-within="setUpdatedWithin"
616632
@toggle-keyword="toggleKeyword"
617633
/>
618-
<!-- Show count status (infinite scroll mode only) -->
619634
<p
620635
v-if="viewMode === 'cards' && paginationMode === 'infinite'"
621636
role="status"
@@ -639,7 +654,6 @@ defineOgImageComponent('Default', {
639654
$t('search.updating')
640655
}}</span>
641656
</p>
642-
<!-- Show "x of y" (paginated/table mode only) -->
643657
<p
644658
v-if="viewMode === 'table' || paginationMode === 'paginated'"
645659
role="status"
@@ -661,13 +675,11 @@ defineOgImageComponent('Default', {
661675
</p>
662676
</div>
663677

664-
<!-- No results found -->
665678
<div v-else-if="status === 'success' || status === 'error'" role="status" class="py-12">
666679
<p class="text-fg-muted font-mono mb-6 text-center">
667680
{{ $t('search.no_results', { query }) }}
668681
</p>
669682

670-
<!-- User/Org suggestions when no packages found -->
671683
<div v-if="validatedSuggestions.length > 0" class="max-w-md mx-auto mb-6 space-y-3">
672684
<SearchSuggestionCard
673685
v-for="(suggestion, idx) in validatedSuggestions"
@@ -682,7 +694,6 @@ defineOgImageComponent('Default', {
682694
/>
683695
</div>
684696

685-
<!-- Offer to claim the package name if it's valid -->
686697
<div v-if="showClaimPrompt" class="max-w-md mx-auto text-center hidden sm:block">
687698
<div class="p-4 bg-bg-subtle border border-border rounded-lg">
688699
<p class="text-sm text-fg-muted mb-3">{{ $t('search.want_to_claim') }}</p>
@@ -698,7 +709,7 @@ defineOgImageComponent('Default', {
698709
</div>
699710

700711
<PackageList
701-
v-if="displayResults.length > 0 && !isRateLimited"
712+
v-show="displayResults.length > 0 && !isRateLimited"
702713
:results="displayResults"
703714
:search-query="query"
704715
:filters="filters"
@@ -719,7 +730,6 @@ defineOgImageComponent('Default', {
719730
@click-keyword="toggleKeyword"
720731
/>
721732

722-
<!-- Pagination controls -->
723733
<PaginationControls
724734
v-if="displayResults.length > 0 && !isRateLimited"
725735
v-model:mode="paginationMode"
@@ -736,7 +746,6 @@ defineOgImageComponent('Default', {
736746
</section>
737747
</div>
738748

739-
<!-- Claim package modal -->
740749
<PackageClaimPackageModal
741750
ref="claimPackageModalRef"
742751
:package-name="query"
@@ -745,3 +754,10 @@ defineOgImageComponent('Default', {
745754
/>
746755
</main>
747756
</template>
757+
758+
<style scoped>
759+
.results-layout {
760+
min-height: 50vh;
761+
overflow-anchor: none;
762+
}
763+
</style>

0 commit comments

Comments
 (0)