[READ-ONLY] a fast, modern browser for the npm registry
at main 235 lines 8.0 kB view raw
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>