[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add collapsible section component (#488)

Co-authored-by: Jens Rømer Hesselbjerg <jh.roemer@gmail.com>
Co-authored-by: Nathan Knowler <nathan@knowler.dev>
Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: jellydeck <91427591+jellydeck@users.noreply.github.com>
Co-authored-by: jyc.dev <jycouet@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

+187 -111
+131
app/components/CollapsibleSection.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, computed } from 'vue' 3 + 4 + interface Props { 5 + title: string 6 + isLoading?: boolean 7 + headingLevel?: `h${number}` 8 + id: string 9 + } 10 + 11 + const props = withDefaults(defineProps<Props>(), { 12 + isLoading: false, 13 + headingLevel: 'h2', 14 + }) 15 + 16 + const appSettings = useSettings() 17 + 18 + const buttonId = `${props.id}-collapsible-button` 19 + const contentId = `${props.id}-collapsible-content` 20 + const headingId = `${props.id}-heading` 21 + 22 + const isOpen = ref(true) 23 + 24 + onPrehydrate(() => { 25 + const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') 26 + const collapsed: string[] = settings?.sidebar?.collapsed || [] 27 + for (const id of collapsed) { 28 + if (!document.documentElement.dataset.collapsed?.includes(id)) { 29 + document.documentElement.dataset.collapsed = ( 30 + document.documentElement.dataset.collapsed + 31 + ' ' + 32 + id 33 + ).trim() 34 + } 35 + } 36 + }) 37 + 38 + onMounted(() => { 39 + if (document?.documentElement) { 40 + isOpen.value = !(document.documentElement.dataset.collapsed?.includes(props.id) ?? false) 41 + } 42 + }) 43 + 44 + function toggle() { 45 + isOpen.value = !isOpen.value 46 + 47 + const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id) 48 + 49 + if (isOpen.value) { 50 + appSettings.settings.value.sidebar.collapsed = removed 51 + } else { 52 + removed.push(props.id) 53 + appSettings.settings.value.sidebar.collapsed = removed 54 + } 55 + 56 + document.documentElement.dataset.collapsed = 57 + appSettings.settings.value.sidebar.collapsed.join(' ') 58 + } 59 + 60 + const ariaLabel = computed(() => { 61 + const action = isOpen.value ? 'Collapse' : 'Expand' 62 + return props.title ? `${action} ${props.title}` : action 63 + }) 64 + useHead({ 65 + style: [ 66 + { 67 + innerHTML: ` 68 + :root[data-collapsed~='${props.id}'] section[data-anchor-id='${props.id}'] .collapsible-content { 69 + grid-template-rows: 0fr; 70 + }`, 71 + }, 72 + ], 73 + }) 74 + </script> 75 + 76 + <template> 77 + <section class="scroll-mt-20" :data-anchor-id="id"> 78 + <div class="flex items-center justify-between mb-3"> 79 + <component 80 + :is="headingLevel" 81 + :id="headingId" 82 + class="group text-xs text-fg-subtle uppercase tracking-wider flex items-center gap-2" 83 + > 84 + <button 85 + :id="buttonId" 86 + type="button" 87 + class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg-muted transition-colors duration-200 shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 88 + :aria-expanded="isOpen" 89 + :aria-controls="contentId" 90 + :aria-label="ariaLabel" 91 + @click="toggle" 92 + > 93 + <span 94 + v-if="isLoading" 95 + class="i-carbon:rotate-180 w-3 h-3 motion-safe:animate-spin" 96 + aria-hidden="true" 97 + /> 98 + <span 99 + v-else 100 + class="w-3 h-3 transition-transform duration-200" 101 + :class="isOpen ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right'" 102 + aria-hidden="true" 103 + /> 104 + </button> 105 + 106 + <a 107 + :href="`#${id}`" 108 + class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 109 + > 110 + {{ title }} 111 + <span 112 + class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 113 + aria-hidden="true" 114 + /> 115 + </a> 116 + </component> 117 + 118 + <!-- Actions slot for buttons or other elements --> 119 + <slot name="actions" /> 120 + </div> 121 + 122 + <div 123 + :id="contentId" 124 + class="grid ms-6 transition-[grid-template-rows] duration-200 ease-in-out collapsible-content overflow-hidden" 125 + > 126 + <div class="min-h-0"> 127 + <slot /> 128 + </div> 129 + </div> 130 + </section> 131 + </template>
+18 -63
app/components/PackageDependencies.vue
··· 70 70 <template> 71 71 <div class="space-y-8"> 72 72 <!-- Dependencies --> 73 - <section id="dependencies" v-if="sortedDependencies.length > 0" class="scroll-mt-20"> 74 - <h2 75 - id="dependencies-heading" 76 - class="group text-xs text-fg-subtle uppercase tracking-wider mb-3" 77 - > 78 - <a 79 - href="#dependencies" 80 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 81 - > 82 - {{ $t('package.dependencies.title', { count: sortedDependencies.length }) }} 83 - <span 84 - class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 85 - aria-hidden="true" 86 - /> 87 - </a> 88 - </h2> 73 + <CollapsibleSection 74 + v-if="sortedDependencies.length > 0" 75 + id="dependencies" 76 + :title="$t('package.dependencies.title', { count: sortedDependencies.length })" 77 + > 89 78 <ul class="space-y-1 list-none m-0 p-0" :aria-label="$t('package.dependencies.list_label')"> 90 79 <li 91 80 v-for="[dep, version] in sortedDependencies.slice(0, depsExpanded ? undefined : 10)" ··· 151 140 </span> 152 141 </li> 153 142 </ul> 154 - <button 155 - v-if="sortedDependencies.length > 10 && !depsExpanded" 156 - type="button" 157 - class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 158 - @click="depsExpanded = true" 159 - > 160 - {{ $t('package.dependencies.show_all', { count: sortedDependencies.length }) }} 161 - </button> 162 - </section> 143 + </CollapsibleSection> 163 144 164 145 <!-- Peer Dependencies --> 165 - <section id="peer-dependencies" v-if="sortedPeerDependencies.length > 0" class="scroll-mt-20"> 166 - <h2 167 - id="peer-dependencies-heading" 168 - class="group text-xs text-fg-subtle uppercase tracking-wider mb-3" 169 - > 170 - <a 171 - href="#peer-dependencies" 172 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 173 - > 174 - {{ $t('package.peer_dependencies.title', { count: sortedPeerDependencies.length }) }} 175 - <span 176 - class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 177 - aria-hidden="true" 178 - /> 179 - </a> 180 - </h2> 146 + <CollapsibleSection 147 + v-if="sortedPeerDependencies.length > 0" 148 + id="peer-dependencies" 149 + :title="$t('package.peer_dependencies.title', { count: sortedPeerDependencies.length })" 150 + > 181 151 <ul 182 152 class="space-y-1 list-none m-0 p-0" 183 153 :aria-label="$t('package.peer_dependencies.list_label')" ··· 223 193 > 224 194 {{ $t('package.peer_dependencies.show_all', { count: sortedPeerDependencies.length }) }} 225 195 </button> 226 - </section> 196 + </CollapsibleSection> 227 197 228 198 <!-- Optional Dependencies --> 229 - <section 199 + <CollapsibleSection 200 + v-if="sortedOptionalDependencies.length > 0" 230 201 id="optional-dependencies" 231 - v-if="sortedOptionalDependencies.length > 0" 232 - class="scroll-mt-20" 202 + :title=" 203 + $t('package.optional_dependencies.title', { count: sortedOptionalDependencies.length }) 204 + " 233 205 > 234 - <h2 235 - id="optional-dependencies-heading" 236 - class="group text-xs text-fg-subtle uppercase tracking-wider mb-3" 237 - > 238 - <a 239 - href="#optional-dependencies" 240 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 241 - > 242 - {{ 243 - $t('package.optional_dependencies.title', { count: sortedOptionalDependencies.length }) 244 - }} 245 - <span 246 - class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 247 - aria-hidden="true" 248 - /> 249 - </a> 250 - </h2> 251 206 <ul 252 207 class="space-y-1 list-none m-0 p-0" 253 208 :aria-label="$t('package.optional_dependencies.list_label')" ··· 286 241 $t('package.optional_dependencies.show_all', { count: sortedOptionalDependencies.length }) 287 242 }} 288 243 </button> 289 - </section> 244 + </CollapsibleSection> 290 245 </div> 291 246 </template>
+11 -19
app/components/PackageVersions.vue
··· 1 1 <script setup lang="ts"> 2 - import type { PackumentVersion, PackageVersionInfo } from '#shared/types' 2 + import type { PackageVersionInfo, PackumentVersion } from '#shared/types' 3 + import { compare } from 'semver' 3 4 import type { RouteLocationRaw } from 'vue-router' 4 - import { compare } from 'semver' 5 + import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' 5 6 import { 6 7 buildVersionToTagsMap, 7 8 filterExcludedTags, ··· 9 10 getVersionGroupKey, 10 11 getVersionGroupLabel, 11 12 isSameVersionGroup, 12 - parseVersion, 13 13 } from '~/utils/versions' 14 - import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' 15 14 16 15 const props = defineProps<{ 17 16 packageName: string ··· 312 311 </script> 313 312 314 313 <template> 315 - <section id="versions" v-if="allTagRows.length > 0" class="overflow-hidden scroll-mt-20"> 316 - <h2 id="versions-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-3"> 317 - <a 318 - href="#versions" 319 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 320 - > 321 - {{ $t('package.versions.title') }} 322 - <span 323 - class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 324 - aria-hidden="true" 325 - /> 326 - </a> 327 - </h2> 328 - 314 + <CollapsibleSection 315 + v-if="allTagRows.length > 0" 316 + :title="$t('package.versions.title')" 317 + id="versions" 318 + > 329 319 <div class="space-y-0.5 min-w-0"> 330 320 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) --> 331 321 <div v-for="row in visibleTagRows" :key="row.id"> ··· 344 334 ? $t('package.versions.collapse', { tag: row.tag }) 345 335 : $t('package.versions.expand', { tag: row.tag }) 346 336 " 337 + data-testid="tag-expand-button" 347 338 @click="expandTagRow(row.tag)" 348 339 > 349 340 <span ··· 593 584 ? $t('package.versions.collapse_major', { major: group.label }) 594 585 : $t('package.versions.expand_major', { major: group.label }) 595 586 " 587 + data-testid="major-group-expand-button" 596 588 @click="toggleMajorGroup(group.groupKey)" 597 589 > 598 590 <span ··· 786 778 </div> 787 779 </div> 788 780 </div> 789 - </section> 781 + </CollapsibleSection> 790 782 </template>
+4 -16
app/components/PackageWeeklyDownloadStats.vue
··· 191 191 192 192 <template> 193 193 <div class="space-y-8"> 194 - <section id="downloads" class="scroll-mt-20"> 195 - <div class="flex items-center justify-between mb-3"> 196 - <h2 class="group text-xs text-fg-subtle uppercase tracking-wider"> 197 - <a 198 - href="#downloads" 199 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 200 - > 201 - {{ $t('package.downloads.title') }} 202 - <span 203 - class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200" 204 - aria-hidden="true" 205 - /> 206 - </a> 207 - </h2> 194 + <CollapsibleSection id="downloads" :title="$t('package.downloads.title')"> 195 + <template #actions> 208 196 <button 209 197 type="button" 210 198 @click="showModal = true" ··· 214 202 <span class="i-carbon:data-analytics w-4 h-4" aria-hidden="true" /> 215 203 <span class="sr-only">{{ $t('package.downloads.analyze') }}</span> 216 204 </button> 217 - </div> 205 + </template> 218 206 219 207 <div class="w-full overflow-hidden"> 220 208 <ClientOnly> ··· 251 239 </template> 252 240 </ClientOnly> 253 241 </div> 254 - </section> 242 + </CollapsibleSection> 255 243 </div> 256 244 257 245 <ChartModal v-model:open="showModal">
+6
app/composables/useSettings.ts
··· 16 16 accentColorId: AccentColorId | null 17 17 /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ 18 18 hidePlatformPackages: boolean 19 + sidebar: { 20 + collapsed: string[] 21 + } 19 22 } 20 23 21 24 const DEFAULT_SETTINGS: AppSettings = { ··· 23 26 includeTypesInInstall: true, 24 27 accentColorId: null, 25 28 hidePlatformPackages: true, 29 + sidebar: { 30 + collapsed: [], 31 + }, 26 32 } 27 33 28 34 const STORAGE_KEY = 'npmx-settings'
+2
app/utils/prehydrate.ts
··· 56 56 57 57 // Set data attribute for CSS-based visibility 58 58 document.documentElement.dataset.pm = pm 59 + 60 + document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? '' 59 61 }) 60 62 }
+15 -13
test/nuxt/components/PackageVersions.spec.ts
··· 338 338 }, 339 339 }) 340 340 341 - const expandButton = component.find('button[aria-expanded]') 341 + const expandButton = component.find('[data-testid="tag-expand-button"]') 342 342 expect(expandButton.exists()).toBe(true) 343 343 expect(expandButton.attributes('aria-expanded')).toBe('false') 344 344 }) ··· 355 355 }, 356 356 }) 357 357 358 - const expandButton = component.find('button[aria-expanded="false"]') 358 + const expandButton = component.find('[data-testid="tag-expand-button"]') 359 359 expect(expandButton.attributes('aria-label')).toBe('Expand latest') 360 360 }) 361 361 ··· 376 376 }, 377 377 }) 378 378 379 - const expandButton = component.find('button[aria-expanded="false"]') 379 + const expandButton = component.find('[data-testid="tag-expand-button"]') 380 380 await expandButton.trigger('click') 381 381 382 382 // Wait for async operation ··· 405 405 }) 406 406 407 407 // Get initial expand button 408 - const expandButton = component.find('button[aria-expanded]') 408 + const expandButton = component.find('[data-testid="tag-expand-button"]') 409 409 expect(expandButton.exists()).toBe(true) 410 410 411 411 // Expand ··· 419 419 // Wait for the component to update after loading 420 420 await vi.waitFor( 421 421 () => { 422 - const btn = component.find('button[aria-expanded="true"]') 422 + const btn = component.find('[data-testid="tag-expand-button"][aria-expanded="true"]') 423 423 expect(btn.exists()).toBe(true) 424 424 }, 425 425 { timeout: 2000 }, 426 426 ) 427 427 428 428 // Now collapse by clicking again 429 - const expandedButton = component.find('button[aria-expanded="true"]') 429 + const expandedButton = component.find( 430 + '[data-testid="tag-expand-button"][aria-expanded="true"]', 431 + ) 430 432 await expandedButton.trigger('click') 431 433 432 434 await vi.waitFor( 433 435 () => { 434 - const btn = component.find('button[aria-expanded="false"]') 436 + const btn = component.find('[data-testid="tag-expand-button"][aria-expanded="false"]') 435 437 expect(btn.exists()).toBe(true) 436 438 }, 437 439 { timeout: 2000 }, ··· 795 797 }) 796 798 797 799 // Click expand 798 - const expandButton = component.find('button[aria-expanded]') 800 + const expandButton = component.find('[data-testid="tag-expand-button"]') 799 801 await expandButton.trigger('click') 800 802 801 803 // Should show loading spinner (animate-spin class) ··· 953 955 }) 954 956 955 957 // Click expand 956 - const expandButton = component.find('button[aria-expanded]') 958 + const expandButton = component.find('[data-testid="tag-expand-button"]') 957 959 await expandButton.trigger('click') 958 960 959 961 // Wait for error to be logged ··· 991 993 }) 992 994 993 995 // Expand first tag row 994 - const expandButtons = component.findAll('button[aria-expanded="false"]') 996 + const expandButtons = component.findAll('[data-testid="tag-expand-button"]') 995 997 await expandButtons[0]?.trigger('click') 996 998 997 999 await vi.waitFor(() => { ··· 999 1001 }) 1000 1002 1001 1003 // Expand second tag row - should not fetch again 1002 - const updatedButtons = component.findAll('button[aria-expanded="false"]') 1003 - if (updatedButtons[0]) { 1004 - await updatedButtons[0].trigger('click') 1004 + const updatedButtons = component.findAll('[data-testid="tag-expand-button"]') 1005 + if (updatedButtons[1]) { 1006 + await updatedButtons[1].trigger('click') 1005 1007 } 1006 1008 1007 1009 // Should still only have been called once