11<script setup lang="ts">
2+ import { WindowVirtualizer } from ' virtua/vue'
23import { getVersions } from ' fast-npm-meta'
34import {
45 buildVersionToTagsMap ,
@@ -54,13 +55,11 @@ const fullVersionMap = ref<Map<
5455 string ,
5556 { time ?: string ; deprecated ?: string ; hasProvenance : boolean }
5657> | null > (null )
57- const hasLoadedFull = ref (false )
5858
5959async function ensureFullDataLoaded() {
60- if (hasLoadedFull .value ) return
60+ if (fullVersionMap .value ) return
6161 const versions = await fetchAllPackageVersions (packageName .value )
6262 fullVersionMap .value = new Map (versions .map (v => [v .version , v ]))
63- hasLoadedFull .value = true
6463}
6564
6665// ─── Derived data ─────────────────────────────────────────────────────────────
@@ -79,7 +78,6 @@ function getVersionTime(version: string): string | undefined {
7978// ─── Version groups ───────────────────────────────────────────────────────────
8079
8180const expandedGroups = ref (new Set <string >())
82- const renderedGroups = ref (new Set <string >())
8381const loadingGroup = ref <string | null >(null )
8482
8583const versionGroups = computed (() => {
@@ -105,22 +103,48 @@ const versionGroups = computed(() => {
105103})
106104
107105async function toggleGroup(groupKey : string ) {
106+ console .log (' toggleGroup' , groupKey )
108107 if (expandedGroups .value .has (groupKey )) {
109108 expandedGroups .value .delete (groupKey )
110109 return
111110 }
112- if (! hasLoadedFull .value ) {
111+ expandedGroups .value .add (groupKey )
112+ console .log (' toggleGroup expanded' , fullVersionMap .value )
113+ console .log (' toggleGroup expanded' , ! fullVersionMap .value )
114+ if (! fullVersionMap .value ) {
113115 loadingGroup .value = groupKey
114116 try {
115117 await ensureFullDataLoaded ()
116118 } finally {
117119 loadingGroup .value = null
118120 }
119121 }
120- renderedGroups .value .add (groupKey )
121- expandedGroups .value .add (groupKey )
122122}
123123
124+ // ─── Flat list for virtual rendering ──────────────────────────────────────────
125+
126+ type FlatItem =
127+ | { type: ' header' ; groupKey: string ; label: string ; versions: string [] }
128+ | { type: ' version' ; version: string ; groupKey: string }
129+
130+ const flatItems = computed <FlatItem []>(() => {
131+ const items: FlatItem [] = []
132+ for (const group of versionGroups .value ) {
133+ items .push ({
134+ type: ' header' ,
135+ groupKey: group .groupKey ,
136+ label: group .label ,
137+ versions: group .versions ,
138+ })
139+ if (expandedGroups .value .has (group .groupKey )) {
140+ for (const version of group .versions ) {
141+ items .push ({ type: ' version' , version , groupKey: group .groupKey })
142+ }
143+ }
144+ }
145+ return items
146+ })
147+
124148// ─── Changelog side panel ─────────────────────────────────────────────────────
125149
126150const selectedChangelogVersion = ref <string | null >(null )
@@ -319,148 +343,151 @@ watch(jumpVersion, () => {
319343
320344 <!-- List + changelog side panel -->
321345 <div class =" flex" >
322- <!-- Version list (grouped by major) -->
346+ <!-- Version list (grouped by major, virtualized ) -->
323347 <div
324348 class =" flex-1 min-w-0 border-y sm:border border-border sm:rounded-lg sm:overflow-hidden"
325349 >
326- <div
327- v-for =" group in versionGroups"
328- :key =" group.groupKey"
329- class =" border-b border-border last:border-0"
330- >
331- <!-- Group header -->
332- <button
333- type =" button"
334- class =" flex items-center gap-3 px-4 py-2.5 w-full text-start hover:bg-bg-subtle transition-colors"
335- :aria-expanded =" expandedGroups.has(group.groupKey)"
336- :aria-label =" `${expandedGroups.has(group.groupKey) ? 'Collapse' : 'Expand'} ${group.label}`"
337- @click =" toggleGroup(group.groupKey)"
338- >
339- <span class =" w-4 h-4 flex items-center justify-center text-fg-subtle shrink-0" >
340- <span
341- v-if =" loadingGroup === group.groupKey"
342- class =" i-svg-spinners:ring-resize w-3 h-3"
343- aria-hidden =" true"
344- />
345- <span
346- v-else
347- class =" i-lucide:chevron-right w-3 h-3 transition-transform duration-200 rtl-flip"
348- :class =" expandedGroups.has(group.groupKey) ? 'rotate-90' : ''"
349- aria-hidden =" true"
350- />
351- </span >
352- <span class =" font-mono text-sm font-medium" >{{ group.label }}</span >
353- <span class =" text-xs text-fg-subtle" >({{ group.versions.length }})</span >
354- <span class =" ms-auto flex items-center gap-3 shrink-0" >
355- <span class =" font-mono text-xs text-fg-muted" dir =" ltr" >{{
356- group.versions[0]
357- }}</span >
358- <DateTime
359- v-if =" getVersionTime(group.versions[0])"
360- :datetime =" getVersionTime(group.versions[0])!"
361- class =" text-xs text-fg-subtle hidden sm:block"
362- year =" numeric"
363- month =" short"
364- day =" numeric"
365- />
366- </span >
367- </button >
368-
369- <!-- Expanded versions -->
370- <div v-show =" expandedGroups.has(group.groupKey)" class =" border-t border-border" >
371- <template v-if =" renderedGroups .has (group .groupKey )" >
350+ <WindowVirtualizer :data =" flatItems" >
351+ <template #default =" { item , index } " >
352+ <!-- ── Group header ── -->
353+ <button
354+ v-if =" item.type === 'header'"
355+ type =" button"
356+ class =" flex items-center gap-3 px-4 py-2.5 w-full text-start hover:bg-bg-subtle transition-colors"
357+ :class =" index < flatItems.length - 1 ? 'border-b border-border' : ''"
358+ :aria-expanded =" expandedGroups.has(item.groupKey)"
359+ :aria-label =" `${expandedGroups.has(item.groupKey) ? 'Collapse' : 'Expand'} ${item.label}`"
360+ @click =" toggleGroup(item.groupKey)"
361+ >
362+ <span class =" w-4 h-4 flex items-center justify-center text-fg-subtle shrink-0" >
363+ <span
364+ v-if =" loadingGroup === item.groupKey"
365+ class =" i-svg-spinners:ring-resize w-3 h-3"
366+ aria-hidden =" true"
367+ />
368+ <span
369+ v-else
370+ class =" i-lucide:chevron-right w-3 h-3 transition-transform duration-200 rtl-flip"
371+ :class =" expandedGroups.has(item.groupKey) ? 'rotate-90' : ''"
372+ aria-hidden =" true"
373+ />
374+ </span >
375+ <span class =" font-mono text-sm font-medium" >{{ item.label }}</span >
376+ <span class =" text-xs text-fg-subtle" >({{ item.versions.length }})</span >
377+ <span class =" ms-auto flex items-center gap-3 shrink-0" >
378+ <span class =" font-mono text-xs text-fg-muted" dir =" ltr" >{{
379+ item.versions[0]
380+ }}</span >
381+ <DateTime
382+ v-if =" getVersionTime(item.versions[0])"
383+ :datetime =" getVersionTime(item.versions[0])!"
384+ class =" text-xs text-fg-subtle hidden sm:block"
385+ year =" numeric"
386+ month =" short"
387+ day =" numeric"
388+ />
389+ </span >
390+ </button >
391+
392+ <!-- ── Version row ── -->
393+ <div
394+ v-else
395+ class =" transition-colors"
396+ :class =" [
397+ index < flatItems.length - 1 ? 'border-b border-border' : '',
398+ selectedChangelogVersion === item.version ? 'bg-bg-subtle' : '',
399+ ]"
400+ >
372401 <div
373- v-for =" v in group.versions"
374- :key =" v"
375- class =" border-b border-border last:border-0 transition-colors"
376- :class =" selectedChangelogVersion === v ? 'bg-bg-subtle' : ''"
402+ class =" flex items-center gap-3 px-4 ps-11 py-2.5 group relative"
403+ :class =" selectedChangelogVersion === item.version ? '' : 'hover:bg-bg-subtle'"
377404 >
378- <div
379- class =" flex items-center gap-3 px-4 ps-11 py-2.5 group relative"
380- :class =" selectedChangelogVersion === v ? '' : 'hover:bg-bg-subtle'"
381- >
382- <!-- Version + badges -->
383- <div class =" flex-1 min-w-0 flex items-center gap-2 flex-wrap" >
384- <LinkBase
385- :to =" packageRoute(packageName, v)"
386- class =" font-mono text-sm after:absolute after:inset-0 after:content-['']"
387- :class ="
388- fullVersionMap?.get(v)?.deprecated
389- ? 'text-red-700 dark:text-red-400'
390- : ''
391- "
392- :classicon ="
393- fullVersionMap?.get(v)?.deprecated
394- ? 'i-lucide:octagon-alert'
395- : undefined
396- "
397- dir =" ltr"
398- >
399- {{ v }}
400- </LinkBase >
401- <div
402- v-if =" versionToTagsMap.get(v)?.length"
403- class =" flex items-center gap-1 flex-wrap relative z-10"
404- >
405- <span
406- v-for =" tag in versionToTagsMap.get(v)"
407- :key =" tag"
408- class =" text-4xs font-semibold uppercase tracking-wide"
409- :class =" tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
410- >
411- {{ tag }}
412- </span >
413- </div >
405+ <!-- Version + badges -->
406+ <div class =" flex-1 min-w-0 flex items-center gap-2 flex-wrap" >
407+ <LinkBase
408+ :to =" packageRoute(packageName, item.version)"
409+ :prefetch =" false"
410+ class =" font-mono text-sm after:absolute after:inset-0 after:content-['']"
411+ :class ="
412+ fullVersionMap?.get(item.version)?.deprecated
413+ ? 'text-red-700 dark:text-red-400'
414+ : ''
415+ "
416+ :classicon ="
417+ fullVersionMap?.get(item.version)?.deprecated
418+ ? 'i-lucide:octagon-alert'
419+ : undefined
420+ "
421+ dir =" ltr"
422+ >
423+ {{ item.version }}
424+ </LinkBase >
425+ <div
426+ v-if =" versionToTagsMap.get(item.version)?.length"
427+ class =" flex items-center gap-1 flex-wrap relative z-10"
428+ >
414429 <span
415- v-if =" fullVersionMap?.get(v)?.deprecated"
416- class =" text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded relative z-10"
417- :title =" fullVersionMap.get(v)!.deprecated"
430+ v-for =" tag in versionToTagsMap.get(item.version)"
431+ :key =" tag"
432+ class =" text-4xs font-semibold uppercase tracking-wide"
433+ :class =" tag === 'latest' ? 'text-accent' : 'text-fg-subtle'"
418434 >
419- deprecated
435+ {{ tag }}
420436 </span >
421437 </div >
438+ <span
439+ v-if =" fullVersionMap?.get(item.version)?.deprecated"
440+ class =" text-3xs font-medium text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded relative z-10"
441+ :title =" fullVersionMap.get(item.version)!.deprecated"
442+ >
443+ deprecated
444+ </span >
445+ </div >
422446
423- <!-- Right side -->
424- <div class =" flex items-center gap-2 shrink-0 relative z-10" >
425- <!-- TODO(atriiy): changelog would be implemented later -->
426-
427- <!-- Metadata: date + provenance -->
428- <DateTime
429- v-if =" getVersionTime(v)"
430- :datetime =" getVersionTime(v)!"
431- class =" text-xs text-fg-subtle hidden sm:block"
432- year =" numeric"
433- month =" short"
434- day =" numeric"
435- />
436- <ProvenanceBadge
437- v-if =" fullVersionMap?.get(v)?.hasProvenance"
438- :package-name =" packageName"
439- :version =" v"
440- compact
441- :linked =" false"
442- />
443- </div >
447+ <!-- Right side -->
448+ <div class =" flex items-center gap-2 shrink-0 relative z-10" >
449+ <!-- TODO(atriiy): changelog would be implemented later -->
450+
451+ <!-- Metadata: date + provenance -->
452+ <DateTime
453+ v-if =" getVersionTime(item.version)"
454+ :datetime =" getVersionTime(item.version)!"
455+ class =" text-xs text-fg-subtle hidden sm:block"
456+ year =" numeric"
457+ month =" short"
458+ day =" numeric"
459+ />
460+ <ProvenanceBadge
461+ v-if =" fullVersionMap?.get(item.version)?.hasProvenance"
462+ :package-name =" packageName"
463+ :version =" item.version"
464+ compact
465+ :linked =" false"
466+ />
444467 </div >
468+ </div >
445469
446- <!-- Mobile inline changelog (below the row, sm and up uses side panel) -->
447- <div
448- v-if =" v in mockChangelogs"
449- class =" grid sm:hidden transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
450- :class ="
451- selectedChangelogVersion === v ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
452- "
453- >
454- <div class =" overflow-hidden" >
455- <div class =" changelog-body border-t border-border px-4 py-3 text-sm" >
456- {{ selectedChangelogVersion === v ? selectedChangelogContent : '' }}
457- </div >
470+ <!-- Mobile inline changelog (below the row, sm and up uses side panel) -->
471+ <div
472+ v-if =" item.version in mockChangelogs"
473+ class =" grid sm:hidden transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
474+ :class ="
475+ selectedChangelogVersion === item.version
476+ ? 'grid-rows-[1fr]'
477+ : 'grid-rows-[0fr]'
478+ "
479+ >
480+ <div class =" overflow-hidden" >
481+ <div class =" changelog-body border-t border-border px-4 py-3 text-sm" >
482+ {{
483+ selectedChangelogVersion === item.version ? selectedChangelogContent : ''
484+ }}
458485 </div >
459486 </div >
460487 </div >
461- </template >
462- </div >
463- </div >
488+ </div >
489+ </template >
490+ </WindowVirtualizer >
464491 </div >
465492
466493 <!-- Changelog side panel (desktop only) -->
0 commit comments