forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { PageSize, PaginationMode, ViewMode } from '#shared/types/preferences'
3import { PAGE_SIZE_OPTIONS } from '#shared/types/preferences'
4
5const props = defineProps<{
6 totalItems: number
7 /** When in table view, force pagination mode (no infinite scroll for tables) */
8 viewMode?: ViewMode
9}>()
10
11const mode = defineModel<PaginationMode>('mode', { required: true })
12const pageSize = defineModel<PageSize>('pageSize', { required: true })
13const currentPage = defineModel<number>('currentPage', { required: true })
14
15const pageSizeSelectValue = computed(() => String(pageSize.value))
16
17// Whether we should show pagination controls (table view always uses pagination)
18const shouldShowControls = computed(() => props.viewMode === 'table' || mode.value === 'paginated')
19
20// Table view forces pagination mode, otherwise use the provided mode
21const effectiveMode = computed<PaginationMode>(() =>
22 shouldShowControls.value ? 'paginated' : 'infinite',
23)
24
25// When 'all' is selected, there's only 1 page with everything
26const isShowingAll = computed(() => pageSize.value === 'all')
27const totalPages = computed(() =>
28 isShowingAll.value ? 1 : Math.ceil(props.totalItems / (pageSize.value as number)),
29)
30
31// Whether to show the mode toggle (hidden in table view since table always uses pagination)
32const showModeToggle = computed(() => props.viewMode !== 'table')
33
34const startItem = computed(() => {
35 if (props.totalItems === 0) return 0
36 if (isShowingAll.value) return 1
37 return (currentPage.value - 1) * (pageSize.value as number) + 1
38})
39
40const endItem = computed(() => {
41 if (isShowingAll.value) return props.totalItems
42 return Math.min(currentPage.value * (pageSize.value as number), props.totalItems)
43})
44
45const canGoPrev = computed(() => currentPage.value > 1)
46const canGoNext = computed(() => currentPage.value < totalPages.value)
47
48function goToPage(page: number) {
49 if (page >= 1 && page <= totalPages.value) {
50 currentPage.value = page
51 }
52}
53
54function goPrev() {
55 if (canGoPrev.value) {
56 currentPage.value = currentPage.value - 1
57 }
58}
59
60function goNext() {
61 if (canGoNext.value) {
62 currentPage.value = currentPage.value + 1
63 }
64}
65
66// Generate visible page numbers with ellipsis
67const visiblePages = computed(() => {
68 const total = totalPages.value
69 const current = currentPage.value
70 const pages: (number | 'ellipsis')[] = []
71
72 if (total <= 7) {
73 // Show all pages
74 for (let i = 1; i <= total; i++) {
75 pages.push(i)
76 }
77 } else {
78 // Always show first page
79 pages.push(1)
80
81 if (current > 3) {
82 pages.push('ellipsis')
83 }
84
85 // Show pages around current
86 const start = Math.max(2, current - 1)
87 const end = Math.min(total - 1, current + 1)
88
89 for (let i = start; i <= end; i++) {
90 pages.push(i)
91 }
92
93 if (current < total - 2) {
94 pages.push('ellipsis')
95 }
96
97 // Always show last page
98 if (total > 1) {
99 pages.push(total)
100 }
101 }
102
103 return pages
104})
105
106function handlePageSizeChange(event: Event) {
107 const target = event.target as HTMLSelectElement
108 const value = target.value
109 // Handle 'all' as a special string value, otherwise parse as number
110 const newSize = (value === 'all' ? 'all' : Number(value)) as PageSize
111 pageSize.value = newSize
112 // Reset to page 1 when changing page size
113 currentPage.value = 1
114}
115</script>
116
117<template>
118 <!-- Only show when in paginated mode (table view or explicit paginated mode) -->
119 <div
120 v-if="shouldShowControls"
121 class="flex flex-wrap items-center justify-between gap-4 py-4 mt-2"
122 >
123 <!-- Left: Mode toggle and page size -->
124 <div class="flex items-center gap-4">
125 <!-- Pagination mode toggle (hidden in table view - tables always use pagination) -->
126 <div
127 v-if="showModeToggle"
128 class="inline-flex rounded-md border border-border p-0.5 bg-bg-subtle"
129 role="group"
130 :aria-label="$t('filters.pagination.mode_label')"
131 >
132 <button
133 type="button"
134 class="px-2.5 py-1 text-xs font-mono rounded-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
135 :class="mode === 'infinite' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
136 :aria-pressed="mode === 'infinite'"
137 @click="mode = 'infinite'"
138 >
139 {{ $t('filters.pagination.infinite') }}
140 </button>
141 <button
142 type="button"
143 class="px-2.5 py-1 text-xs font-mono rounded-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
144 :class="mode === 'paginated' ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg'"
145 :aria-pressed="mode === 'paginated'"
146 @click="mode = 'paginated'"
147 >
148 {{ $t('filters.pagination.paginated') }}
149 </button>
150 </div>
151
152 <!-- Page size (shown when paginated or table view) -->
153 <div v-if="effectiveMode === 'paginated'" class="relative shrink-0">
154 <SelectField
155 :label="$t('filters.pagination.items_per_page')"
156 hidden-label
157 id="page-size"
158 v-model="pageSizeSelectValue"
159 @change="handlePageSizeChange"
160 :items="
161 PAGE_SIZE_OPTIONS.map(size => ({
162 label:
163 size === 'all'
164 ? $t('filters.pagination.all_yolo')
165 : $t('filters.pagination.per_page', { count: size }),
166 value: String(size),
167 }))
168 "
169 />
170 </div>
171 </div>
172
173 <!-- Right: Page info and navigation (paginated mode only) -->
174 <div v-if="effectiveMode === 'paginated'" class="flex items-center gap-4">
175 <!-- Showing X-Y of Z -->
176 <span class="text-sm font-mono text-fg-muted">
177 {{
178 $t('filters.pagination.showing', {
179 start: startItem,
180 end: endItem,
181 total: $n(totalItems),
182 })
183 }}
184 </span>
185
186 <!-- Page navigation -->
187 <nav
188 v-if="totalPages > 1"
189 class="flex items-center gap-1"
190 :aria-label="$t('filters.pagination.nav_label')"
191 >
192 <!-- Previous button -->
193 <button
194 type="button"
195 class="p-1.5 rounded hover:bg-bg-muted text-fg-muted hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
196 :disabled="!canGoPrev"
197 :aria-label="$t('filters.pagination.previous')"
198 @click="goPrev"
199 >
200 <span class="i-lucide:chevron-left block rtl-flip w-4 h-4" aria-hidden="true" />
201 </button>
202
203 <!-- Page numbers -->
204 <template v-for="(page, idx) in visiblePages" :key="idx">
205 <span v-if="page === 'ellipsis'" class="px-2 text-fg-subtle font-mono">…</span>
206 <button
207 v-else
208 type="button"
209 class="min-w-[32px] h-8 px-2 font-mono text-sm rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
210 :class="
211 page === currentPage
212 ? 'bg-fg text-bg'
213 : 'text-fg-muted hover:text-fg hover:bg-bg-muted'
214 "
215 :aria-current="page === currentPage ? 'page' : undefined"
216 @click="goToPage(page)"
217 >
218 {{ page }}
219 </button>
220 </template>
221
222 <!-- Next button -->
223 <button
224 type="button"
225 class="p-1.5 rounded hover:bg-bg-muted text-fg-muted hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
226 :disabled="!canGoNext"
227 :aria-label="$t('filters.pagination.next')"
228 @click="goNext"
229 >
230 <span class="i-lucide:chevron-right block rtl-flip w-4 h-4" aria-hidden="true" />
231 </button>
232 </nav>
233 </div>
234 </div>
235</template>