[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 package comparison feature (#383)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Philippe Serhal
Daniel Roe
and committed by
GitHub
73e50ef8 f8f43732

+2917 -9
+9
app/components/AppHeader.vue
··· 153 153 154 154 <!-- End: Desktop nav items + Mobile menu button --> 155 155 <div class="flex-shrink-0 flex items-center gap-4 sm:gap-6"> 156 + <!-- Desktop: Compare link --> 157 + <NuxtLink 158 + to="/compare" 159 + class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 160 + > 161 + <span class="i-carbon:compare w-4 h-4" aria-hidden="true" /> 162 + {{ $t('nav.compare') }} 163 + </NuxtLink> 164 + 156 165 <!-- Desktop: Settings link --> 157 166 <NuxtLink 158 167 to="/settings"
+2 -2
app/components/CollapsibleSection.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, computed } from 'vue' 2 + import { shallowRef, computed } from 'vue' 3 3 4 4 interface Props { 5 5 title: string ··· 19 19 const contentId = `${props.id}-collapsible-content` 20 20 const headingId = `${props.id}-heading` 21 21 22 - const isOpen = ref(true) 22 + const isOpen = shallowRef(true) 23 23 24 24 onPrehydrate(() => { 25 25 const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
+9
app/components/MobileMenu.vue
··· 100 100 </NuxtLink> 101 101 102 102 <NuxtLink 103 + to="/compare" 104 + class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 105 + @click="closeMenu" 106 + > 107 + <span class="i-carbon:compare w-5 h-5 text-fg-muted" aria-hidden="true" /> 108 + {{ $t('nav.compare') }} 109 + </NuxtLink> 110 + 111 + <NuxtLink 103 112 to="/settings" 104 113 class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200" 105 114 @click="closeMenu"
+80
app/components/compare/ComparisonGrid.vue
··· 1 + <script setup lang="ts"> 2 + defineProps<{ 3 + /** Number of columns (2-4) */ 4 + columns: number 5 + /** Column headers (package names or version numbers) */ 6 + headers: string[] 7 + }>() 8 + </script> 9 + 10 + <template> 11 + <div class="overflow-x-auto"> 12 + <div 13 + class="comparison-grid" 14 + :class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]" 15 + :style="{ '--columns': columns }" 16 + > 17 + <!-- Header row --> 18 + <div class="comparison-header"> 19 + <div class="comparison-label" /> 20 + <div 21 + v-for="(header, index) in headers" 22 + :key="index" 23 + class="comparison-cell comparison-cell-header" 24 + > 25 + <span class="font-mono text-sm font-medium text-fg truncate" :title="header"> 26 + {{ header }} 27 + </span> 28 + </div> 29 + </div> 30 + 31 + <!-- Facet rows --> 32 + <slot /> 33 + </div> 34 + </div> 35 + </template> 36 + 37 + <style scoped> 38 + .comparison-grid { 39 + display: grid; 40 + gap: 0; 41 + } 42 + 43 + .comparison-grid.columns-2 { 44 + grid-template-columns: minmax(120px, 180px) repeat(2, 1fr); 45 + } 46 + 47 + .comparison-grid.columns-3 { 48 + grid-template-columns: minmax(120px, 160px) repeat(3, 1fr); 49 + } 50 + 51 + .comparison-grid.columns-4 { 52 + grid-template-columns: minmax(100px, 140px) repeat(4, 1fr); 53 + } 54 + 55 + .comparison-header { 56 + display: contents; 57 + } 58 + 59 + .comparison-header > .comparison-label { 60 + padding: 0.75rem 1rem; 61 + border-bottom: 1px solid var(--color-border); 62 + } 63 + 64 + .comparison-header > .comparison-cell-header { 65 + padding: 0.75rem 1rem; 66 + background: var(--color-bg-subtle); 67 + border-bottom: 1px solid var(--color-border); 68 + text-align: center; 69 + } 70 + 71 + /* First header cell rounded top-start */ 72 + .comparison-header > .comparison-cell-header:first-of-type { 73 + border-start-start-radius: 0.5rem; 74 + } 75 + 76 + /* Last header cell rounded top-end */ 77 + .comparison-header > .comparison-cell-header:last-of-type { 78 + border-start-end-radius: 0.5rem; 79 + } 80 + </style>
+140
app/components/compare/FacetCard.vue
··· 1 + <script setup lang="ts"> 2 + import type { FacetValue } from '#shared/types' 3 + 4 + const props = defineProps<{ 5 + /** Facet label */ 6 + label: string 7 + /** Description/tooltip for the facet */ 8 + description?: string 9 + /** Values for each column */ 10 + values: (FacetValue | null | undefined)[] 11 + /** Whether this facet is loading (e.g., install size) */ 12 + facetLoading?: boolean 13 + /** Whether each column is loading (array matching values) */ 14 + columnLoading?: boolean[] 15 + /** Whether to show the proportional bar (defaults to true for numeric values) */ 16 + bar?: boolean 17 + /** Package headers for display */ 18 + headers: string[] 19 + }>() 20 + 21 + // Check if all values are numeric (for bar visualization) 22 + const isNumeric = computed(() => { 23 + return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number') 24 + }) 25 + 26 + // Show bar if explicitly enabled, or if not specified and values are numeric 27 + const showBar = computed(() => { 28 + return props.bar ?? isNumeric.value 29 + }) 30 + 31 + // Get max value for bar width calculation 32 + const maxValue = computed(() => { 33 + if (!isNumeric.value) return 0 34 + return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0))) 35 + }) 36 + 37 + // Calculate bar width percentage for a value 38 + function getBarWidth(value: FacetValue | null | undefined): number { 39 + if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0 40 + return (value.raw / maxValue.value) * 100 41 + } 42 + 43 + function getStatusClass(status?: FacetValue['status']): string { 44 + switch (status) { 45 + case 'good': 46 + return 'text-emerald-400' 47 + case 'info': 48 + return 'text-blue-400' 49 + case 'warning': 50 + return 'text-amber-400' 51 + case 'bad': 52 + return 'text-red-400' 53 + default: 54 + return 'text-fg' 55 + } 56 + } 57 + 58 + // Check if a specific cell is loading 59 + function isCellLoading(index: number): boolean { 60 + return props.facetLoading || (props.columnLoading?.[index] ?? false) 61 + } 62 + 63 + // Get short package name (without version) for mobile display 64 + function getShortName(header: string): string { 65 + const atIndex = header.lastIndexOf('@') 66 + if (atIndex > 0) { 67 + return header.substring(0, atIndex) 68 + } 69 + return header 70 + } 71 + </script> 72 + 73 + <template> 74 + <div class="border border-border rounded-lg overflow-hidden"> 75 + <!-- Facet header --> 76 + <div class="flex items-center gap-1.5 px-3 py-2 bg-bg-subtle border-b border-border"> 77 + <span class="text-xs text-fg-muted uppercase tracking-wider font-medium">{{ label }}</span> 78 + <span 79 + v-if="description" 80 + class="i-carbon:information w-3 h-3 text-fg-subtle" 81 + :title="description" 82 + aria-hidden="true" 83 + /> 84 + </div> 85 + 86 + <!-- Package values --> 87 + <div class="divide-y divide-border"> 88 + <div 89 + v-for="(value, index) in values" 90 + :key="index" 91 + class="relative flex items-center justify-between gap-2 px-3 py-2" 92 + > 93 + <!-- Background bar for numeric values --> 94 + <div 95 + v-if="showBar && value && getBarWidth(value) > 0" 96 + class="absolute inset-y-0 inset-is-0 bg-fg/5 transition-all duration-300" 97 + :style="{ width: `${getBarWidth(value)}%` }" 98 + aria-hidden="true" 99 + /> 100 + 101 + <!-- Package name --> 102 + <span 103 + class="relative font-mono text-xs text-fg-muted truncate flex-shrink min-w-0" 104 + :title="headers[index]" 105 + > 106 + {{ getShortName(headers[index] ?? '') }} 107 + </span> 108 + 109 + <!-- Value --> 110 + <span class="relative flex-shrink-0"> 111 + <!-- Loading state --> 112 + <template v-if="isCellLoading(index)"> 113 + <span 114 + class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin" 115 + aria-hidden="true" 116 + /> 117 + </template> 118 + 119 + <!-- No data --> 120 + <template v-else-if="!value"> 121 + <span class="text-fg-subtle text-sm">-</span> 122 + </template> 123 + 124 + <!-- Value display --> 125 + <template v-else> 126 + <span class="font-mono text-sm tabular-nums" :class="getStatusClass(value.status)"> 127 + <!-- Date values use DateTime component for i18n and user settings --> 128 + <DateTime 129 + v-if="value.type === 'date'" 130 + :datetime="value.display" 131 + date-style="medium" 132 + /> 133 + <template v-else>{{ value.display }}</template> 134 + </span> 135 + </template> 136 + </span> 137 + </div> 138 + </div> 139 + </div> 140 + </template>
+114
app/components/compare/FacetRow.vue
··· 1 + <script setup lang="ts"> 2 + import type { FacetValue } from '#shared/types' 3 + 4 + const props = defineProps<{ 5 + /** Facet label */ 6 + label: string 7 + /** Description/tooltip for the facet */ 8 + description?: string 9 + /** Values for each column */ 10 + values: (FacetValue | null | undefined)[] 11 + /** Whether this facet is loading (e.g., install size) */ 12 + facetLoading?: boolean 13 + /** Whether each column is loading (array matching values) */ 14 + columnLoading?: boolean[] 15 + /** Whether to show the proportional bar (defaults to true for numeric values) */ 16 + bar?: boolean 17 + }>() 18 + 19 + // Check if all values are numeric (for bar visualization) 20 + const isNumeric = computed(() => { 21 + return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number') 22 + }) 23 + 24 + // Show bar if explicitly enabled, or if not specified and values are numeric 25 + const showBar = computed(() => { 26 + return props.bar ?? isNumeric.value 27 + }) 28 + 29 + // Get max value for bar width calculation 30 + const maxValue = computed(() => { 31 + if (!isNumeric.value) return 0 32 + return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0))) 33 + }) 34 + 35 + // Calculate bar width percentage for a value 36 + function getBarWidth(value: FacetValue | null | undefined): number { 37 + if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0 38 + return (value.raw / maxValue.value) * 100 39 + } 40 + 41 + function getStatusClass(status?: FacetValue['status']): string { 42 + switch (status) { 43 + case 'good': 44 + return 'text-emerald-400' 45 + case 'info': 46 + return 'text-blue-400' 47 + case 'warning': 48 + return 'text-amber-400' 49 + case 'bad': 50 + return 'text-red-400' 51 + default: 52 + return 'text-fg' 53 + } 54 + } 55 + 56 + // Check if a specific cell is loading 57 + function isCellLoading(index: number): boolean { 58 + return props.facetLoading || (props.columnLoading?.[index] ?? false) 59 + } 60 + </script> 61 + 62 + <template> 63 + <div class="contents"> 64 + <!-- Label cell --> 65 + <div 66 + class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border" 67 + :title="description" 68 + > 69 + <span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span> 70 + <span 71 + v-if="description" 72 + class="i-carbon:information w-3 h-3 text-fg-subtle" 73 + aria-hidden="true" 74 + /> 75 + </div> 76 + 77 + <!-- Value cells --> 78 + <div 79 + v-for="(value, index) in values" 80 + :key="index" 81 + class="comparison-cell relative flex items-end justify-center px-4 py-3 border-b border-border" 82 + > 83 + <!-- Background bar for numeric values --> 84 + <div 85 + v-if="showBar && value && getBarWidth(value) > 0" 86 + class="absolute inset-y-1 inset-is-1 bg-fg/5 rounded-sm transition-all duration-300" 87 + :style="{ width: `calc(${getBarWidth(value)}% - 8px)` }" 88 + aria-hidden="true" 89 + /> 90 + 91 + <!-- Loading state --> 92 + <template v-if="isCellLoading(index)"> 93 + <span 94 + class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin" 95 + aria-hidden="true" 96 + /> 97 + </template> 98 + 99 + <!-- No data --> 100 + <template v-else-if="!value"> 101 + <span class="text-fg-subtle text-sm">-</span> 102 + </template> 103 + 104 + <!-- Value display --> 105 + <template v-else> 106 + <span class="relative font-mono text-sm tabular-nums" :class="getStatusClass(value.status)"> 107 + <!-- Date values use DateTime component for i18n and user settings --> 108 + <DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" /> 109 + <template v-else>{{ value.display }}</template> 110 + </span> 111 + </template> 112 + </div> 113 + </div> 114 + </template>
+127
app/components/compare/FacetSelector.vue
··· 1 + <script setup lang="ts"> 2 + import type { ComparisonFacet } from '#shared/types' 3 + import { FACET_INFO, FACETS_BY_CATEGORY, CATEGORY_ORDER } from '#shared/types/comparison' 4 + 5 + const { 6 + isFacetSelected, 7 + toggleFacet, 8 + selectCategory, 9 + deselectCategory, 10 + selectAll, 11 + deselectAll, 12 + isAllSelected, 13 + isNoneSelected, 14 + } = useFacetSelection() 15 + 16 + // Enrich facets with their info for rendering 17 + const facetsByCategory = computed(() => { 18 + const result: Record< 19 + string, 20 + { facet: ComparisonFacet; info: (typeof FACET_INFO)[ComparisonFacet] }[] 21 + > = {} 22 + for (const category of CATEGORY_ORDER) { 23 + result[category] = FACETS_BY_CATEGORY[category].map(facet => ({ 24 + facet, 25 + info: FACET_INFO[facet], 26 + })) 27 + } 28 + return result 29 + }) 30 + 31 + // Check if all non-comingSoon facets in a category are selected 32 + function isCategoryAllSelected(category: string): boolean { 33 + const facets = facetsByCategory.value[category] ?? [] 34 + const selectableFacets = facets.filter(f => !f.info.comingSoon) 35 + return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet)) 36 + } 37 + 38 + // Check if no facets in a category are selected 39 + function isCategoryNoneSelected(category: string): boolean { 40 + const facets = facetsByCategory.value[category] ?? [] 41 + const selectableFacets = facets.filter(f => !f.info.comingSoon) 42 + return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet)) 43 + } 44 + </script> 45 + 46 + <template> 47 + <div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')"> 48 + <div v-for="category in CATEGORY_ORDER" :key="category"> 49 + <!-- Category header with all/none buttons --> 50 + <div class="flex items-center gap-2 mb-2"> 51 + <span class="text-[10px] text-fg-subtle uppercase tracking-wider"> 52 + {{ $t(`compare.facets.categories.${category}`) }} 53 + </span> 54 + <button 55 + type="button" 56 + class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline" 57 + :class=" 58 + isCategoryAllSelected(category) 59 + ? 'text-fg-muted' 60 + : 'text-fg-muted/60 hover:text-fg-muted' 61 + " 62 + :aria-label=" 63 + $t('compare.facets.select_category', { 64 + category: $t(`compare.facets.categories.${category}`), 65 + }) 66 + " 67 + :disabled="isCategoryAllSelected(category)" 68 + @click="selectCategory(category)" 69 + > 70 + {{ $t('compare.facets.all') }} 71 + </button> 72 + <span class="text-[10px] text-fg-muted/40">/</span> 73 + <button 74 + type="button" 75 + class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline" 76 + :class=" 77 + isCategoryNoneSelected(category) 78 + ? 'text-fg-muted' 79 + : 'text-fg-muted/60 hover:text-fg-muted' 80 + " 81 + :aria-label=" 82 + $t('compare.facets.deselect_category', { 83 + category: $t(`compare.facets.categories.${category}`), 84 + }) 85 + " 86 + :disabled="isCategoryNoneSelected(category)" 87 + @click="deselectCategory(category)" 88 + > 89 + {{ $t('compare.facets.none') }} 90 + </button> 91 + </div> 92 + 93 + <!-- Facet buttons --> 94 + <div class="flex items-center gap-1.5 flex-wrap" role="group"> 95 + <button 96 + v-for="{ facet, info } in facetsByCategory[category]" 97 + :key="facet" 98 + type="button" 99 + :title="info.comingSoon ? $t('compare.facets.coming_soon') : info.description" 100 + :disabled="info.comingSoon" 101 + :aria-pressed="isFacetSelected(facet)" 102 + :aria-label="info.label" 103 + class="inline-flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 104 + :class=" 105 + info.comingSoon 106 + ? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed' 107 + : isFacetSelected(facet) 108 + ? 'text-fg-muted bg-bg-muted border-border' 109 + : 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border' 110 + " 111 + @click="!info.comingSoon && toggleFacet(facet)" 112 + > 113 + <span 114 + v-if="!info.comingSoon" 115 + class="w-3 h-3" 116 + :class="isFacetSelected(facet) ? 'i-carbon:checkmark' : 'i-carbon:add'" 117 + aria-hidden="true" 118 + /> 119 + {{ info.label }} 120 + <span v-if="info.comingSoon" class="text-[9px]" 121 + >({{ $t('compare.facets.coming_soon') }})</span 122 + > 123 + </button> 124 + </div> 125 + </div> 126 + </div> 127 + </template>
+149
app/components/compare/PackageSelector.vue
··· 1 + <script setup lang="ts"> 2 + const packages = defineModel<string[]>({ required: true }) 3 + 4 + const props = defineProps<{ 5 + /** Maximum number of packages allowed */ 6 + max?: number 7 + }>() 8 + 9 + const maxPackages = computed(() => props.max ?? 4) 10 + 11 + // Input state 12 + const inputValue = shallowRef('') 13 + const isInputFocused = shallowRef(false) 14 + 15 + // Use the shared npm search composable 16 + const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 }) 17 + 18 + const isSearching = computed(() => status.value === 'pending') 19 + 20 + // Filter out already selected packages 21 + const filteredResults = computed(() => { 22 + if (!searchData.value?.objects) return [] 23 + return searchData.value.objects 24 + .map(o => ({ 25 + name: o.package.name, 26 + description: o.package.description, 27 + })) 28 + .filter(r => !packages.value.includes(r.name)) 29 + }) 30 + 31 + function addPackage(name: string) { 32 + if (packages.value.length >= maxPackages.value) return 33 + if (packages.value.includes(name)) return 34 + 35 + packages.value = [...packages.value, name] 36 + inputValue.value = '' 37 + } 38 + 39 + function removePackage(name: string) { 40 + packages.value = packages.value.filter(p => p !== name) 41 + } 42 + 43 + function handleKeydown(e: KeyboardEvent) { 44 + if (e.key === 'Enter' && inputValue.value.trim()) { 45 + e.preventDefault() 46 + addPackage(inputValue.value.trim()) 47 + } 48 + } 49 + 50 + function handleBlur() { 51 + useTimeoutFn(() => { 52 + isInputFocused.value = false 53 + }, 200) 54 + } 55 + </script> 56 + 57 + <template> 58 + <div class="space-y-3"> 59 + <!-- Selected packages --> 60 + <div v-if="packages.length > 0" class="flex flex-wrap gap-2"> 61 + <div 62 + v-for="pkg in packages" 63 + :key="pkg" 64 + class="inline-flex items-center gap-2 px-3 py-1.5 bg-bg-subtle border border-border rounded-md" 65 + > 66 + <NuxtLink 67 + :to="`/${pkg}`" 68 + class="font-mono text-sm text-fg hover:text-accent transition-colors" 69 + > 70 + {{ pkg }} 71 + </NuxtLink> 72 + <button 73 + type="button" 74 + class="text-fg-subtle hover:text-fg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded" 75 + :aria-label="$t('compare.selector.remove_package', { package: pkg })" 76 + @click="removePackage(pkg)" 77 + > 78 + <span class="i-carbon:close block w-3.5 h-3.5" aria-hidden="true" /> 79 + </button> 80 + </div> 81 + </div> 82 + 83 + <!-- Add package input --> 84 + <div v-if="packages.length < maxPackages" class="relative"> 85 + <div class="relative"> 86 + <label for="package-search" class="sr-only"> 87 + {{ $t('compare.selector.search_label') }} 88 + </label> 89 + <span 90 + class="absolute inset-is-3 top-1/2 -translate-y-1/2 text-fg-subtle" 91 + aria-hidden="true" 92 + > 93 + <span class="i-carbon:search inline-block w-4 h-4" /> 94 + </span> 95 + <input 96 + id="package-search" 97 + v-model="inputValue" 98 + type="text" 99 + :placeholder=" 100 + packages.length === 0 101 + ? $t('compare.selector.search_first') 102 + : $t('compare.selector.search_add') 103 + " 104 + class="w-full bg-bg-subtle border border-border rounded-lg ps-10 pe-4 py-2.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-accent focus-visible:outline-none" 105 + aria-autocomplete="list" 106 + @focus="isInputFocused = true" 107 + @blur="handleBlur" 108 + @keydown="handleKeydown" 109 + /> 110 + </div> 111 + 112 + <!-- Search results dropdown --> 113 + <Transition 114 + enter-active-class="transition-opacity duration-150" 115 + enter-from-class="opacity-0" 116 + leave-active-class="transition-opacity duration-100" 117 + leave-from-class="opacity-100" 118 + leave-to-class="opacity-0" 119 + > 120 + <div 121 + v-if="isInputFocused && (filteredResults.length > 0 || isSearching)" 122 + class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto" 123 + > 124 + <div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted"> 125 + {{ $t('compare.selector.searching') }} 126 + </div> 127 + <button 128 + v-for="result in filteredResults" 129 + :key="result.name" 130 + type="button" 131 + class="w-full text-left px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted" 132 + @click="addPackage(result.name)" 133 + > 134 + <div class="font-mono text-sm text-fg">{{ result.name }}</div> 135 + <div v-if="result.description" class="text-xs text-fg-muted truncate mt-0.5"> 136 + {{ result.description }} 137 + </div> 138 + </button> 139 + </div> 140 + </Transition> 141 + </div> 142 + 143 + <!-- Hint --> 144 + <p class="text-xs text-fg-subtle"> 145 + {{ $t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }} 146 + <span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span> 147 + </p> 148 + </div> 149 + </template>
+124
app/composables/useFacetSelection.ts
··· 1 + import type { ComparisonFacet } from '#shared/types' 2 + import { ALL_FACETS, DEFAULT_FACETS, FACET_INFO } from '#shared/types/comparison' 3 + import { useRouteQuery } from '@vueuse/router' 4 + 5 + /** 6 + * Composable for managing comparison facet selection with URL sync. 7 + * 8 + * @public 9 + * @param queryParam - The URL query parameter name to use (default: 'facets') 10 + */ 11 + export function useFacetSelection(queryParam = 'facets') { 12 + // Sync with URL query param (stable ref - doesn't change on other query changes) 13 + const facetsParam = useRouteQuery<string>(queryParam, '', { mode: 'replace' }) 14 + 15 + // Parse facets from URL or use defaults 16 + const selectedFacets = computed<ComparisonFacet[]>({ 17 + get() { 18 + if (!facetsParam.value) { 19 + return DEFAULT_FACETS 20 + } 21 + 22 + // Parse comma-separated facets and filter valid, non-comingSoon ones 23 + const parsed = facetsParam.value 24 + .split(',') 25 + .map(f => f.trim()) 26 + .filter( 27 + (f): f is ComparisonFacet => 28 + ALL_FACETS.includes(f as ComparisonFacet) && 29 + !FACET_INFO[f as ComparisonFacet].comingSoon, 30 + ) 31 + 32 + return parsed.length > 0 ? parsed : DEFAULT_FACETS 33 + }, 34 + set(facets) { 35 + if (facets.length === 0 || arraysEqual(facets, DEFAULT_FACETS)) { 36 + // Remove param if using defaults 37 + facetsParam.value = '' 38 + } else { 39 + facetsParam.value = facets.join(',') 40 + } 41 + }, 42 + }) 43 + 44 + // Check if a facet is selected 45 + function isFacetSelected(facet: ComparisonFacet): boolean { 46 + return selectedFacets.value.includes(facet) 47 + } 48 + 49 + // Toggle a single facet 50 + function toggleFacet(facet: ComparisonFacet): void { 51 + const current = selectedFacets.value 52 + if (current.includes(facet)) { 53 + // Don't allow deselecting all facets 54 + if (current.length > 1) { 55 + selectedFacets.value = current.filter(f => f !== facet) 56 + } 57 + } else { 58 + selectedFacets.value = [...current, facet] 59 + } 60 + } 61 + 62 + // Get facets in a category (excluding coming soon) 63 + function getFacetsInCategory(category: string): ComparisonFacet[] { 64 + return ALL_FACETS.filter(f => { 65 + const info = FACET_INFO[f] 66 + return info.category === category && !info.comingSoon 67 + }) 68 + } 69 + 70 + // Select all facets in a category 71 + function selectCategory(category: string): void { 72 + const categoryFacets = getFacetsInCategory(category) 73 + const current = selectedFacets.value 74 + const newFacets = [...new Set([...current, ...categoryFacets])] 75 + selectedFacets.value = newFacets 76 + } 77 + 78 + // Deselect all facets in a category 79 + function deselectCategory(category: string): void { 80 + const categoryFacets = getFacetsInCategory(category) 81 + const remaining = selectedFacets.value.filter(f => !categoryFacets.includes(f)) 82 + // Don't allow deselecting all facets 83 + if (remaining.length > 0) { 84 + selectedFacets.value = remaining 85 + } 86 + } 87 + 88 + // Select all facets globally 89 + function selectAll(): void { 90 + selectedFacets.value = DEFAULT_FACETS 91 + } 92 + 93 + // Deselect all facets globally (keeps first facet to ensure at least one) 94 + function deselectAll(): void { 95 + selectedFacets.value = [DEFAULT_FACETS[0] as ComparisonFacet] 96 + } 97 + 98 + // Check if all facets are selected 99 + const isAllSelected = computed(() => selectedFacets.value.length === DEFAULT_FACETS.length) 100 + 101 + // Check if only one facet is selected (minimum) 102 + const isNoneSelected = computed(() => selectedFacets.value.length === 1) 103 + 104 + return { 105 + selectedFacets, 106 + isFacetSelected, 107 + toggleFacet, 108 + selectCategory, 109 + deselectCategory, 110 + selectAll, 111 + deselectAll, 112 + isAllSelected, 113 + isNoneSelected, 114 + allFacets: ALL_FACETS, 115 + } 116 + } 117 + 118 + // Helper to compare arrays 119 + function arraysEqual<T>(a: T[], b: T[]): boolean { 120 + if (a.length !== b.length) return false 121 + const sortedA = [...a].sort() 122 + const sortedB = [...b].sort() 123 + return sortedA.every((val, i) => val === sortedB[i]) 124 + }
+353
app/composables/usePackageComparison.ts
··· 1 + import type { FacetValue, ComparisonFacet, ComparisonPackage } from '#shared/types' 2 + import type { PackageAnalysisResponse } from './usePackageAnalysis' 3 + 4 + export interface PackageComparisonData { 5 + package: ComparisonPackage 6 + downloads?: number 7 + /** Package's own unpacked size (from dist.unpackedSize) */ 8 + packageSize?: number 9 + /** Install size data (fetched lazily) */ 10 + installSize?: { 11 + selfSize: number 12 + totalSize: number 13 + dependencyCount: number 14 + } 15 + analysis?: PackageAnalysisResponse 16 + vulnerabilities?: { 17 + count: number 18 + severity: { critical: number; high: number; medium: number; low: number } 19 + } 20 + metadata?: { 21 + license?: string 22 + lastUpdated?: string 23 + engines?: { node?: string; npm?: string } 24 + deprecated?: string 25 + } 26 + } 27 + 28 + /** 29 + * Composable for fetching and comparing multiple packages. 30 + * 31 + * @public 32 + */ 33 + export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) { 34 + const packages = computed(() => toValue(packageNames)) 35 + 36 + // Cache of fetched data by package name (source of truth) 37 + const cache = shallowRef(new Map<string, PackageComparisonData>()) 38 + 39 + // Derived array in current package order 40 + const packagesData = computed(() => packages.value.map(name => cache.value.get(name) ?? null)) 41 + 42 + const status = shallowRef<'idle' | 'pending' | 'success' | 'error'>('idle') 43 + const error = shallowRef<Error | null>(null) 44 + 45 + // Track which packages are currently being fetched 46 + const loadingPackages = shallowRef(new Set<string>()) 47 + 48 + // Track install size loading separately (it's slower) 49 + const installSizeLoading = shallowRef(false) 50 + 51 + // Fetch function - only fetches packages not already in cache 52 + async function fetchPackages(names: string[]) { 53 + if (names.length === 0) { 54 + status.value = 'idle' 55 + return 56 + } 57 + 58 + // Only fetch packages not already cached 59 + const namesToFetch = names.filter(name => !cache.value.has(name)) 60 + 61 + if (namesToFetch.length === 0) { 62 + status.value = 'success' 63 + return 64 + } 65 + 66 + status.value = 'pending' 67 + error.value = null 68 + 69 + // Mark packages as loading 70 + loadingPackages.value = new Set(namesToFetch) 71 + 72 + try { 73 + // First pass: fetch fast data (package info, downloads, analysis, vulns) 74 + const results = await Promise.all( 75 + namesToFetch.map(async (name): Promise<PackageComparisonData | null> => { 76 + try { 77 + // Fetch basic package info first (required) 78 + const pkgData = await $fetch<{ 79 + 'name': string 80 + 'dist-tags': Record<string, string> 81 + 'time': Record<string, string> 82 + 'license'?: string 83 + 'versions': Record<string, { dist?: { unpackedSize?: number }; deprecated?: string }> 84 + }>(`https://registry.npmjs.org/${encodePackageName(name)}`) 85 + 86 + const latestVersion = pkgData['dist-tags']?.latest 87 + if (!latestVersion) return null 88 + 89 + // Fetch fast additional data in parallel (optional - failures are ok) 90 + const [downloads, analysis, vulns] = await Promise.all([ 91 + $fetch<{ downloads: number }>( 92 + `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, 93 + ).catch(() => null), 94 + $fetch<PackageAnalysisResponse>(`/api/registry/analysis/${name}`).catch(() => null), 95 + $fetch<{ 96 + vulnerabilities: Array<{ severity: string }> 97 + }>(`/api/registry/vulnerabilities/${name}`).catch(() => null), 98 + ]) 99 + 100 + const versionData = pkgData.versions[latestVersion] 101 + const packageSize = versionData?.dist?.unpackedSize 102 + 103 + // Count vulnerabilities by severity 104 + const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 } 105 + const vulnList = vulns?.vulnerabilities ?? [] 106 + for (const v of vulnList) { 107 + const sev = v.severity.toLowerCase() as keyof typeof vulnCounts 108 + if (sev in vulnCounts) vulnCounts[sev]++ 109 + } 110 + 111 + return { 112 + package: { 113 + name: pkgData.name, 114 + version: latestVersion, 115 + description: undefined, 116 + }, 117 + downloads: downloads?.downloads, 118 + packageSize, 119 + installSize: undefined, // Will be filled in second pass 120 + analysis: analysis ?? undefined, 121 + vulnerabilities: { 122 + count: vulnList.length, 123 + severity: vulnCounts, 124 + }, 125 + metadata: { 126 + license: pkgData.license, 127 + lastUpdated: pkgData.time?.modified, 128 + engines: analysis?.engines, 129 + deprecated: versionData?.deprecated, 130 + }, 131 + } 132 + } catch { 133 + return null 134 + } 135 + }), 136 + ) 137 + 138 + // Add results to cache 139 + const newCache = new Map(cache.value) 140 + for (const [i, name] of namesToFetch.entries()) { 141 + const data = results[i] 142 + if (data) { 143 + newCache.set(name, data) 144 + } 145 + } 146 + cache.value = newCache 147 + loadingPackages.value = new Set() 148 + status.value = 'success' 149 + 150 + // Second pass: fetch slow install size data in background for new packages 151 + installSizeLoading.value = true 152 + Promise.all( 153 + namesToFetch.map(async name => { 154 + try { 155 + const installSize = await $fetch<{ 156 + selfSize: number 157 + totalSize: number 158 + dependencyCount: number 159 + }>(`/api/registry/install-size/${name}`) 160 + 161 + // Update cache with install size 162 + const existing = cache.value.get(name) 163 + if (existing) { 164 + const updated = new Map(cache.value) 165 + updated.set(name, { ...existing, installSize }) 166 + cache.value = updated 167 + } 168 + } catch { 169 + // Install size fetch failed, leave as undefined 170 + } 171 + }), 172 + ).finally(() => { 173 + installSizeLoading.value = false 174 + }) 175 + } catch (e) { 176 + loadingPackages.value = new Set() 177 + error.value = e as Error 178 + status.value = 'error' 179 + } 180 + } 181 + 182 + // Watch for package changes and refetch (client-side only) 183 + if (import.meta.client) { 184 + watch( 185 + packages, 186 + newPackages => { 187 + fetchPackages(newPackages) 188 + }, 189 + { immediate: true }, 190 + ) 191 + } 192 + 193 + // Compute values for each facet 194 + function getFacetValues(facet: ComparisonFacet): (FacetValue | null)[] { 195 + if (!packagesData.value || packagesData.value.length === 0) return [] 196 + 197 + return packagesData.value.map(pkg => { 198 + if (!pkg) return null 199 + return computeFacetValue(facet, pkg) 200 + }) 201 + } 202 + 203 + // Check if a facet depends on slow-loading data 204 + function isFacetLoading(facet: ComparisonFacet): boolean { 205 + if (!installSizeLoading.value) return false 206 + // These facets depend on install-size API 207 + return facet === 'installSize' || facet === 'dependencies' 208 + } 209 + 210 + // Check if a specific column (package) is loading 211 + function isColumnLoading(index: number): boolean { 212 + const name = packages.value[index] 213 + return name ? loadingPackages.value.has(name) : false 214 + } 215 + 216 + return { 217 + packagesData: readonly(packagesData), 218 + status: readonly(status), 219 + error: readonly(error), 220 + getFacetValues, 221 + isFacetLoading, 222 + isColumnLoading, 223 + } 224 + } 225 + 226 + function encodePackageName(name: string): string { 227 + if (name.startsWith('@')) { 228 + return `@${encodeURIComponent(name.slice(1))}` 229 + } 230 + return encodeURIComponent(name) 231 + } 232 + 233 + function computeFacetValue(facet: ComparisonFacet, data: PackageComparisonData): FacetValue | null { 234 + switch (facet) { 235 + case 'downloads': 236 + if (data.downloads === undefined) return null 237 + return { 238 + raw: data.downloads, 239 + display: formatCompactNumber(data.downloads), 240 + status: 'neutral', 241 + } 242 + 243 + case 'packageSize': 244 + if (!data.packageSize) return null 245 + return { 246 + raw: data.packageSize, 247 + display: formatBytes(data.packageSize), 248 + status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral', 249 + } 250 + 251 + case 'installSize': 252 + if (!data.installSize) return null 253 + return { 254 + raw: data.installSize.totalSize, 255 + display: formatBytes(data.installSize.totalSize), 256 + status: data.installSize.totalSize > 50 * 1024 * 1024 ? 'warning' : 'neutral', 257 + } 258 + 259 + case 'moduleFormat': 260 + if (!data.analysis) return null 261 + const format = data.analysis.moduleFormat 262 + return { 263 + raw: format, 264 + display: format === 'dual' ? 'ESM + CJS' : format.toUpperCase(), 265 + status: format === 'esm' || format === 'dual' ? 'good' : 'neutral', 266 + } 267 + 268 + case 'types': 269 + if (!data.analysis) return null 270 + const types = data.analysis.types 271 + return { 272 + raw: types.kind, 273 + display: 274 + types.kind === 'included' ? 'Included' : types.kind === '@types' ? '@types' : 'None', 275 + status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad', 276 + } 277 + 278 + case 'engines': 279 + const engines = data.metadata?.engines 280 + if (!engines?.node) return { raw: null, display: 'Any', status: 'neutral' } 281 + return { 282 + raw: engines.node, 283 + display: `Node ${engines.node}`, 284 + status: 'neutral', 285 + } 286 + 287 + case 'vulnerabilities': 288 + if (!data.vulnerabilities) return null 289 + const count = data.vulnerabilities.count 290 + const sev = data.vulnerabilities.severity 291 + return { 292 + raw: count, 293 + display: count === 0 ? 'None' : `${count} (${sev.critical}C/${sev.high}H)`, 294 + status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning', 295 + } 296 + 297 + case 'lastUpdated': 298 + if (!data.metadata?.lastUpdated) return null 299 + const date = new Date(data.metadata.lastUpdated) 300 + return { 301 + raw: date.getTime(), 302 + display: data.metadata.lastUpdated, 303 + status: isStale(date) ? 'warning' : 'neutral', 304 + type: 'date', 305 + } 306 + 307 + case 'license': 308 + const license = data.metadata?.license 309 + if (!license) return { raw: null, display: 'Unknown', status: 'warning' } 310 + return { 311 + raw: license, 312 + display: license, 313 + status: 'neutral', 314 + } 315 + 316 + case 'dependencies': 317 + if (!data.installSize) return null 318 + const depCount = data.installSize.dependencyCount 319 + return { 320 + raw: depCount, 321 + display: String(depCount), 322 + status: depCount > 50 ? 'warning' : 'neutral', 323 + } 324 + 325 + case 'deprecated': 326 + const isDeprecated = !!data.metadata?.deprecated 327 + return { 328 + raw: isDeprecated, 329 + display: isDeprecated ? 'Deprecated' : 'No', 330 + status: isDeprecated ? 'bad' : 'good', 331 + } 332 + 333 + // Coming soon facets 334 + case 'totalDependencies': 335 + return null 336 + 337 + default: 338 + return null 339 + } 340 + } 341 + 342 + function formatBytes(bytes: number): string { 343 + if (bytes < 1024) return `${bytes} B` 344 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` 345 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 346 + } 347 + 348 + function isStale(date: Date): boolean { 349 + const now = new Date() 350 + const diffMs = now.getTime() - date.getTime() 351 + const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365) 352 + return diffYears > 2 // Considered stale if not updated in 2+ years 353 + }
+32 -3
app/pages/[...package].vue
··· 353 353 { dedupe: true }, 354 354 ) 355 355 356 + onKeyStroke('c', () => { 357 + if (pkg.value) { 358 + router.push({ path: '/compare', query: { packages: pkg.value.name } }) 359 + } 360 + }) 361 + 356 362 defineOgImageComponent('Package', { 357 363 name: () => pkg.value?.name ?? 'Package', 358 364 version: () => displayVersion.value?.version ?? '', ··· 470 476 </template> 471 477 </ClientOnly> 472 478 473 - <!-- Internal navigation: Docs + Code (hidden on mobile, shown in external links instead) --> 479 + <!-- Internal navigation: Docs + Code + Compare (hidden on mobile, shown in external links instead) --> 474 480 <nav 475 481 v-if="displayVersion" 476 482 :aria-label="$t('package.navigation')" 477 - class="hidden sm:flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ms-auto self-center" 483 + class="hidden sm:flex items-center gap-0.5 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ms-auto self-center" 478 484 > 479 485 <NuxtLink 480 486 v-if="docsLink" ··· 508 514 . 509 515 </kbd> 510 516 </NuxtLink> 517 + <NuxtLink 518 + :to="{ path: '/compare', query: { packages: pkg.name } }" 519 + class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5" 520 + aria-keyshortcuts="c" 521 + > 522 + <span class="i-carbon:compare w-3 h-3" aria-hidden="true" /> 523 + {{ $t('package.links.compare') }} 524 + <kbd 525 + class="inline-flex items-center justify-center w-4 h-4 text-xs bg-bg-muted border border-border rounded" 526 + aria-hidden="true" 527 + > 528 + c 529 + </kbd> 530 + </NuxtLink> 511 531 </nav> 512 532 </div> 513 533 ··· 616 636 {{ $t('package.links.fund') }} 617 637 </a> 618 638 </li> 619 - <!-- Mobile-only: Docs + Code links --> 639 + <!-- Mobile-only: Docs + Code + Compare links --> 620 640 <li v-if="docsLink && displayVersion" class="sm:hidden"> 621 641 <NuxtLink 622 642 :to="docsLink" ··· 636 656 > 637 657 <span class="i-carbon:code w-4 h-4" aria-hidden="true" /> 638 658 {{ $t('package.links.code') }} 659 + </NuxtLink> 660 + </li> 661 + <li class="sm:hidden"> 662 + <NuxtLink 663 + :to="{ path: '/compare', query: { packages: pkg.name } }" 664 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 665 + > 666 + <span class="i-carbon:compare w-4 h-4" aria-hidden="true" /> 667 + {{ $t('package.links.compare') }} 639 668 </NuxtLink> 640 669 </li> 641 670 </ul>
+176
app/pages/compare.vue
··· 1 + <script setup lang="ts"> 2 + import { FACET_INFO, type ComparisonFacet } from '#shared/types/comparison' 3 + import { useRouteQuery } from '@vueuse/router' 4 + 5 + definePageMeta({ 6 + name: 'compare', 7 + }) 8 + 9 + // Sync packages with URL query param (stable ref - doesn't change on other query changes) 10 + const packagesParam = useRouteQuery<string>('packages', '', { mode: 'replace' }) 11 + 12 + // Parse package names from comma-separated string 13 + const packages = computed({ 14 + get() { 15 + if (!packagesParam.value) return [] 16 + return packagesParam.value 17 + .split(',') 18 + .map(p => p.trim()) 19 + .filter(p => p.length > 0) 20 + .slice(0, 4) 21 + }, 22 + set(value) { 23 + packagesParam.value = value.length > 0 ? value.join(',') : '' 24 + }, 25 + }) 26 + 27 + // Facet selection 28 + const { selectedFacets, selectAll, deselectAll, isAllSelected, isNoneSelected } = 29 + useFacetSelection() 30 + 31 + // Fetch comparison data 32 + const { packagesData, status, getFacetValues, isFacetLoading, isColumnLoading } = 33 + usePackageComparison(packages) 34 + 35 + // Get loading state for each column 36 + const columnLoading = computed(() => packages.value.map((_, i) => isColumnLoading(i))) 37 + 38 + // Check if we have enough packages to compare 39 + const canCompare = computed(() => packages.value.length >= 2) 40 + 41 + // Get headers for the grid 42 + const gridHeaders = computed(() => { 43 + if (!packagesData.value) return packages.value 44 + return packagesData.value.map((p, i) => 45 + p ? `${p.package.name}@${p.package.version}` : (packages.value[i] ?? ''), 46 + ) 47 + }) 48 + 49 + useSeoMeta({ 50 + title: () => 51 + packages.value.length > 0 52 + ? $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) 53 + : $t('compare.packages.meta_title_empty'), 54 + description: () => 55 + packages.value.length > 0 56 + ? $t('compare.packages.meta_description', { packages: packages.value.join(', ') }) 57 + : $t('compare.packages.meta_description_empty'), 58 + }) 59 + </script> 60 + 61 + <template> 62 + <main class="container flex-1 py-12 sm:py-16 w-full"> 63 + <div class="max-w-2xl mx-auto"> 64 + <header class="mb-12"> 65 + <h1 class="font-mono text-3xl sm:text-4xl font-medium mb-4"> 66 + {{ $t('compare.packages.title') }} 67 + </h1> 68 + <p class="text-fg-muted text-lg"> 69 + {{ $t('compare.packages.tagline') }} 70 + </p> 71 + </header> 72 + 73 + <!-- Package selector --> 74 + <section class="mb-8" aria-labelledby="packages-heading"> 75 + <h2 id="packages-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3"> 76 + {{ $t('compare.packages.section_packages') }} 77 + </h2> 78 + <ComparePackageSelector v-model="packages" :max="4" /> 79 + </section> 80 + 81 + <!-- Facet selector --> 82 + <section class="mb-8" aria-labelledby="facets-heading"> 83 + <div class="flex items-center gap-2 mb-3"> 84 + <h2 id="facets-heading" class="text-xs text-fg-subtle uppercase tracking-wider"> 85 + {{ $t('compare.packages.section_facets') }} 86 + </h2> 87 + <button 88 + type="button" 89 + class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline" 90 + :class="isAllSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'" 91 + :disabled="isAllSelected" 92 + :aria-label="$t('compare.facets.select_all')" 93 + @click="selectAll" 94 + > 95 + {{ $t('compare.facets.all') }} 96 + </button> 97 + <span class="text-[10px] text-fg-muted/40" aria-hidden="true">/</span> 98 + <button 99 + type="button" 100 + class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline" 101 + :class="isNoneSelected ? 'text-fg-muted' : 'text-fg-muted/60 hover:text-fg-muted'" 102 + :disabled="isNoneSelected" 103 + :aria-label="$t('compare.facets.deselect_all')" 104 + @click="deselectAll" 105 + > 106 + {{ $t('compare.facets.none') }} 107 + </button> 108 + </div> 109 + <CompareFacetSelector /> 110 + </section> 111 + 112 + <!-- Comparison grid --> 113 + <section v-if="canCompare" class="mt-10" aria-labelledby="comparison-heading"> 114 + <h2 id="comparison-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-4"> 115 + {{ $t('compare.packages.section_comparison') }} 116 + </h2> 117 + 118 + <div 119 + v-if="status === 'pending' && (!packagesData || packagesData.every(p => p === null))" 120 + class="flex items-center justify-center py-12" 121 + > 122 + <LoadingSpinner :text="$t('compare.packages.loading')" /> 123 + </div> 124 + 125 + <div v-else-if="packagesData && packagesData.some(p => p !== null)"> 126 + <!-- Desktop: Grid layout --> 127 + <div class="hidden md:block overflow-x-auto"> 128 + <CompareComparisonGrid :columns="packages.length" :headers="gridHeaders"> 129 + <CompareFacetRow 130 + v-for="facet in selectedFacets" 131 + :key="facet" 132 + :label="FACET_INFO[facet].label" 133 + :description="FACET_INFO[facet].description" 134 + :values="getFacetValues(facet)" 135 + :facet-loading="isFacetLoading(facet)" 136 + :column-loading="columnLoading" 137 + :bar="facet !== 'lastUpdated'" 138 + :headers="gridHeaders" 139 + /> 140 + </CompareComparisonGrid> 141 + </div> 142 + 143 + <!-- Mobile: Card-based layout --> 144 + <div class="md:hidden space-y-3"> 145 + <CompareFacetCard 146 + v-for="facet in selectedFacets" 147 + :key="facet" 148 + :label="FACET_INFO[facet].label" 149 + :description="FACET_INFO[facet].description" 150 + :values="getFacetValues(facet)" 151 + :facet-loading="isFacetLoading(facet)" 152 + :column-loading="columnLoading" 153 + :bar="facet !== 'lastUpdated'" 154 + :headers="gridHeaders" 155 + /> 156 + </div> 157 + </div> 158 + 159 + <div v-else class="text-center py-12" role="alert"> 160 + <p class="text-fg-muted">{{ $t('compare.packages.error') }}</p> 161 + </div> 162 + </section> 163 + 164 + <!-- Empty state --> 165 + <section v-else class="text-center py-16 border border-dashed border-border rounded-lg"> 166 + <div class="i-carbon:compare w-12 h-12 text-fg-subtle mx-auto mb-4" aria-hidden="true" /> 167 + <h2 class="font-mono text-lg text-fg-muted mb-2"> 168 + {{ $t('compare.packages.empty_title') }} 169 + </h2> 170 + <p class="text-sm text-fg-subtle max-w-md mx-auto"> 171 + {{ $t('compare.packages.empty_description') }} 172 + </p> 173 + </section> 174 + </div> 175 + </main> 176 + </template>
+47 -1
i18n/locales/en.json
··· 46 46 "popular_packages": "Popular packages", 47 47 "search": "search", 48 48 "settings": "settings", 49 + "compare": "compare", 49 50 "back": "back", 50 51 "menu": "Menu", 51 52 "mobile_menu": "Navigation menu", ··· 156 157 "jsr": "jsr", 157 158 "code": "code", 158 159 "docs": "docs", 159 - "fund": "fund" 160 + "fund": "fund", 161 + "compare": "compare" 160 162 }, 161 163 "docs": { 162 164 "not_available": "Docs not available", ··· 789 791 "error": "Failed to load organizations", 790 792 "empty": "No organizations found", 791 793 "view_all": "View all" 794 + } 795 + }, 796 + "compare": { 797 + "packages": { 798 + "title": "compare packages", 799 + "tagline": "compare npm packages side-by-side to help you choose the right one.", 800 + "meta_title": "Compare {packages} - npmx", 801 + "meta_title_empty": "Compare Packages - npmx", 802 + "meta_description": "Side-by-side comparison of {packages}", 803 + "meta_description_empty": "Compare npm packages side-by-side", 804 + "section_packages": "Packages", 805 + "section_facets": "Facets", 806 + "section_comparison": "Comparison", 807 + "loading": "Loading package data...", 808 + "error": "Failed to load package data. Please try again.", 809 + "empty_title": "Select packages to compare", 810 + "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics." 811 + }, 812 + "selector": { 813 + "search_label": "Search for packages", 814 + "search_first": "Search for a package...", 815 + "search_add": "Add another package...", 816 + "searching": "Searching...", 817 + "remove_package": "Remove {package}", 818 + "packages_selected": "{count}/{max} packages selected.", 819 + "add_hint": "Add at least 2 packages to compare.", 820 + "loading_versions": "Loading versions...", 821 + "select_version": "Select version" 822 + }, 823 + "facets": { 824 + "group_label": "Comparison facets", 825 + "all": "all", 826 + "none": "none", 827 + "coming_soon": "Coming soon", 828 + "select_all": "Select all facets", 829 + "deselect_all": "Deselect all facets", 830 + "select_category": "Select all {category} facets", 831 + "deselect_category": "Deselect all {category} facets", 832 + "categories": { 833 + "performance": "Performance", 834 + "health": "Health", 835 + "compatibility": "Compatibility", 836 + "security": "Security & Compliance" 837 + } 792 838 } 793 839 } 794 840 }
+46 -1
i18n/locales/fr-FR.json
··· 137 137 "jsr": "jsr", 138 138 "code": "code", 139 139 "docs": "docs", 140 - "fund": "donner" 140 + "fund": "donner", 141 + "compare": "comparer" 141 142 }, 142 143 "docs": { 143 144 "not_available": "Documentation non disponible", ··· 752 753 "error": "Échec du chargement des organisations", 753 754 "empty": "Aucune organisation trouvée", 754 755 "view_all": "Tout voir" 756 + } 757 + }, 758 + "compare": { 759 + "packages": { 760 + "title": "Comparer les paquets", 761 + "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.", 762 + "meta_title": "Comparer {packages} - npmx", 763 + "meta_title_empty": "Comparer les paquets - npmx", 764 + "meta_description": "Comparaison côte à côte de {packages}", 765 + "meta_description_empty": "Comparez les paquets npm côte à côte", 766 + "section_packages": "Paquets", 767 + "section_facets": "Facettes", 768 + "section_comparison": "Comparaison", 769 + "loading": "Chargement des données des paquets...", 770 + "error": "Échec du chargement des données. Veuillez réessayer.", 771 + "empty_title": "Sélectionnez des paquets à comparer", 772 + "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs facettes." 773 + }, 774 + "selector": { 775 + "search_label": "Rechercher des paquets", 776 + "search_first": "Rechercher un paquet...", 777 + "search_add": "Ajouter un autre paquet...", 778 + "searching": "Recherche...", 779 + "remove_package": "Supprimer {package}", 780 + "packages_selected": "{count}/{max} paquets sélectionnés.", 781 + "add_hint": "Ajoutez au moins 2 paquets à comparer.", 782 + "loading_versions": "Chargement des versions...", 783 + "select_version": "Sélectionner une version" 784 + }, 785 + "facets": { 786 + "group_label": "Facettes de comparaison", 787 + "all": "tout", 788 + "none": "aucun", 789 + "coming_soon": "Bientôt disponible", 790 + "select_all": "Sélectionner toutes les facettes", 791 + "deselect_all": "Désélectionner toutes les facettes", 792 + "select_category": "Sélectionner toutes les facettes {category}", 793 + "deselect_category": "Désélectionner toutes les facettes {category}", 794 + "categories": { 795 + "performance": "Performance", 796 + "health": "Santé", 797 + "compatibility": "Compatibilité", 798 + "security": "Sécurité & Conformité" 799 + } 755 800 } 756 801 }, 757 802 "version": "Version",
+47 -1
lunaria/files/en-US.json
··· 46 46 "popular_packages": "Popular packages", 47 47 "search": "search", 48 48 "settings": "settings", 49 + "compare": "compare", 49 50 "back": "back", 50 51 "menu": "Menu", 51 52 "mobile_menu": "Navigation menu", ··· 156 157 "jsr": "jsr", 157 158 "code": "code", 158 159 "docs": "docs", 159 - "fund": "fund" 160 + "fund": "fund", 161 + "compare": "compare" 160 162 }, 161 163 "docs": { 162 164 "not_available": "Docs not available", ··· 789 791 "error": "Failed to load organizations", 790 792 "empty": "No organizations found", 791 793 "view_all": "View all" 794 + } 795 + }, 796 + "compare": { 797 + "packages": { 798 + "title": "compare packages", 799 + "tagline": "compare npm packages side-by-side to help you choose the right one.", 800 + "meta_title": "Compare {packages} - npmx", 801 + "meta_title_empty": "Compare Packages - npmx", 802 + "meta_description": "Side-by-side comparison of {packages}", 803 + "meta_description_empty": "Compare npm packages side-by-side", 804 + "section_packages": "Packages", 805 + "section_facets": "Facets", 806 + "section_comparison": "Comparison", 807 + "loading": "Loading package data...", 808 + "error": "Failed to load package data. Please try again.", 809 + "empty_title": "Select packages to compare", 810 + "empty_description": "Search and add at least 2 packages above to see a side-by-side comparison of their metrics." 811 + }, 812 + "selector": { 813 + "search_label": "Search for packages", 814 + "search_first": "Search for a package...", 815 + "search_add": "Add another package...", 816 + "searching": "Searching...", 817 + "remove_package": "Remove {package}", 818 + "packages_selected": "{count}/{max} packages selected.", 819 + "add_hint": "Add at least 2 packages to compare.", 820 + "loading_versions": "Loading versions...", 821 + "select_version": "Select version" 822 + }, 823 + "facets": { 824 + "group_label": "Comparison facets", 825 + "all": "all", 826 + "none": "none", 827 + "coming_soon": "Coming soon", 828 + "select_all": "Select all facets", 829 + "deselect_all": "Deselect all facets", 830 + "select_category": "Select all {category} facets", 831 + "deselect_category": "Deselect all {category} facets", 832 + "categories": { 833 + "performance": "Performance", 834 + "health": "Health", 835 + "compatibility": "Compatibility", 836 + "security": "Security & Compliance" 837 + } 792 838 } 793 839 } 794 840 }
+46 -1
lunaria/files/fr-FR.json
··· 137 137 "jsr": "jsr", 138 138 "code": "code", 139 139 "docs": "docs", 140 - "fund": "donner" 140 + "fund": "donner", 141 + "compare": "comparer" 141 142 }, 142 143 "docs": { 143 144 "not_available": "Documentation non disponible", ··· 752 753 "error": "Échec du chargement des organisations", 753 754 "empty": "Aucune organisation trouvée", 754 755 "view_all": "Tout voir" 756 + } 757 + }, 758 + "compare": { 759 + "packages": { 760 + "title": "Comparer les paquets", 761 + "tagline": "Comparez les paquets npm côte à côte pour vous aider à choisir le bon.", 762 + "meta_title": "Comparer {packages} - npmx", 763 + "meta_title_empty": "Comparer les paquets - npmx", 764 + "meta_description": "Comparaison côte à côte de {packages}", 765 + "meta_description_empty": "Comparez les paquets npm côte à côte", 766 + "section_packages": "Paquets", 767 + "section_facets": "Facettes", 768 + "section_comparison": "Comparaison", 769 + "loading": "Chargement des données des paquets...", 770 + "error": "Échec du chargement des données. Veuillez réessayer.", 771 + "empty_title": "Sélectionnez des paquets à comparer", 772 + "empty_description": "Recherchez et ajoutez au moins 2 paquets ci-dessus pour voir une comparaison côte à côte de leurs facettes." 773 + }, 774 + "selector": { 775 + "search_label": "Rechercher des paquets", 776 + "search_first": "Rechercher un paquet...", 777 + "search_add": "Ajouter un autre paquet...", 778 + "searching": "Recherche...", 779 + "remove_package": "Supprimer {package}", 780 + "packages_selected": "{count}/{max} paquets sélectionnés.", 781 + "add_hint": "Ajoutez au moins 2 paquets à comparer.", 782 + "loading_versions": "Chargement des versions...", 783 + "select_version": "Sélectionner une version" 784 + }, 785 + "facets": { 786 + "group_label": "Facettes de comparaison", 787 + "all": "tout", 788 + "none": "aucun", 789 + "coming_soon": "Bientôt disponible", 790 + "select_all": "Sélectionner toutes les facettes", 791 + "deselect_all": "Désélectionner toutes les facettes", 792 + "select_category": "Sélectionner toutes les facettes {category}", 793 + "deselect_category": "Désélectionner toutes les facettes {category}", 794 + "categories": { 795 + "performance": "Performance", 796 + "health": "Santé", 797 + "compatibility": "Compatibilité", 798 + "security": "Sécurité & Conformité" 799 + } 755 800 } 756 801 }, 757 802 "version": "Version",
+1
package.json
··· 58 58 "@vercel/kv": "3.0.0", 59 59 "@vueuse/core": "14.1.0", 60 60 "@vueuse/nuxt": "14.1.0", 61 + "@vueuse/router": "^14.1.0", 61 62 "module-replacements": "2.11.0", 62 63 "nuxt": "4.3.0", 63 64 "nuxt-og-image": "5.1.13",
+15
pnpm-lock.yaml
··· 83 83 '@vueuse/nuxt': 84 84 specifier: 14.1.0 85 85 version: 14.1.0(magicast@0.5.1)(nuxt@4.3.0(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.3))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) 86 + '@vueuse/router': 87 + specifier: ^14.1.0 88 + version: 14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) 86 89 module-replacements: 87 90 specifier: 2.11.0 88 91 version: 2.11.0 ··· 4407 4410 peerDependencies: 4408 4411 nuxt: ^3.0.0 || ^4.0.0-0 4409 4412 vue: ^3.5.0 4413 + 4414 + '@vueuse/router@14.1.0': 4415 + resolution: {integrity: sha512-8h7g0PhjcMC2Vnu9zBkN1J038JFIzkS3/DP2L5ouzFEhY3YAM8zkIOZ0K+hzAWkYEFLGmWGcgBfuvCUD0U42Jw==} 4416 + peerDependencies: 4417 + vue: ^3.5.0 4418 + vue-router: ^4.0.0 4410 4419 4411 4420 '@vueuse/shared@10.11.1': 4412 4421 resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} ··· 14056 14065 vue: 3.5.27(typescript@5.9.3) 14057 14066 transitivePeerDependencies: 14058 14067 - magicast 14068 + 14069 + '@vueuse/router@14.1.0(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': 14070 + dependencies: 14071 + '@vueuse/shared': 14.1.0(vue@3.5.27(typescript@5.9.3)) 14072 + vue: 3.5.27(typescript@5.9.3) 14073 + vue-router: 4.6.4(vue@3.5.27(typescript@5.9.3)) 14059 14074 14060 14075 '@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))': 14061 14076 dependencies:
+137
shared/types/comparison.ts
··· 1 + /** 2 + * Comparison feature types 3 + */ 4 + 5 + /** Available comparison facets */ 6 + export type ComparisonFacet = 7 + | 'downloads' 8 + | 'packageSize' 9 + | 'installSize' 10 + | 'moduleFormat' 11 + | 'types' 12 + | 'engines' 13 + | 'vulnerabilities' 14 + | 'lastUpdated' 15 + | 'license' 16 + | 'dependencies' 17 + | 'totalDependencies' 18 + | 'deprecated' 19 + 20 + /** Facet metadata for UI display */ 21 + export interface FacetInfo { 22 + id: ComparisonFacet 23 + label: string 24 + description: string 25 + category: 'performance' | 'health' | 'compatibility' | 'security' 26 + comingSoon?: boolean 27 + } 28 + 29 + /** Category display order */ 30 + export const CATEGORY_ORDER = ['performance', 'health', 'compatibility', 'security'] as const 31 + 32 + /** All available facets with their metadata (ordered by category, then display order within category) */ 33 + export const FACET_INFO: Record<ComparisonFacet, Omit<FacetInfo, 'id'>> = { 34 + // Performance 35 + packageSize: { 36 + label: 'Package Size', 37 + description: 'Size of the package itself (unpacked)', 38 + category: 'performance', 39 + }, 40 + installSize: { 41 + label: 'Install Size', 42 + description: 'Total install size including all dependencies', 43 + category: 'performance', 44 + }, 45 + dependencies: { 46 + label: '# Direct Deps', 47 + description: 'Number of direct dependencies', 48 + category: 'performance', 49 + }, 50 + totalDependencies: { 51 + label: '# Total Deps', 52 + description: 'Total number of dependencies including transitive', 53 + category: 'performance', 54 + comingSoon: true, 55 + }, 56 + // Health 57 + downloads: { 58 + label: 'Downloads/wk', 59 + description: 'Weekly download count', 60 + category: 'health', 61 + }, 62 + lastUpdated: { 63 + label: 'Last Updated', 64 + description: 'Most recent publish date', 65 + category: 'health', 66 + }, 67 + deprecated: { 68 + label: 'Deprecated?', 69 + description: 'Whether the package is deprecated', 70 + category: 'health', 71 + }, 72 + // Compatibility 73 + engines: { 74 + label: 'Engines', 75 + description: 'Node.js version requirements', 76 + category: 'compatibility', 77 + }, 78 + types: { 79 + label: 'Types', 80 + description: 'TypeScript type definitions', 81 + category: 'compatibility', 82 + }, 83 + moduleFormat: { 84 + label: 'Module Format', 85 + description: 'ESM/CJS support', 86 + category: 'compatibility', 87 + }, 88 + // Security 89 + license: { 90 + label: 'License', 91 + description: 'Package license', 92 + category: 'security', 93 + }, 94 + vulnerabilities: { 95 + label: 'Vulnerabilities', 96 + description: 'Known security vulnerabilities', 97 + category: 'security', 98 + }, 99 + } 100 + 101 + /** All facets in display order */ 102 + export const ALL_FACETS: ComparisonFacet[] = Object.keys(FACET_INFO) as ComparisonFacet[] 103 + 104 + /** Facets grouped by category (derived from FACET_INFO) */ 105 + export const FACETS_BY_CATEGORY: Record<FacetInfo['category'], ComparisonFacet[]> = 106 + ALL_FACETS.reduce( 107 + (acc, facet) => { 108 + acc[FACET_INFO[facet].category].push(facet) 109 + return acc 110 + }, 111 + { performance: [], health: [], compatibility: [], security: [] } as Record< 112 + FacetInfo['category'], 113 + ComparisonFacet[] 114 + >, 115 + ) 116 + 117 + /** Default facets - all non-comingSoon facets */ 118 + export const DEFAULT_FACETS: ComparisonFacet[] = ALL_FACETS.filter(f => !FACET_INFO[f].comingSoon) 119 + 120 + /** Facet value that can be compared */ 121 + export interface FacetValue<T = unknown> { 122 + /** Raw value for comparison logic */ 123 + raw: T 124 + /** Formatted display string (or ISO date string if type is 'date') */ 125 + display: string 126 + /** Optional status indicator */ 127 + status?: 'good' | 'info' | 'warning' | 'bad' | 'neutral' 128 + /** Value type for special rendering (e.g., dates use DateTime component) */ 129 + type?: 'date' 130 + } 131 + 132 + /** Package data for comparison */ 133 + export interface ComparisonPackage { 134 + name: string 135 + version: string 136 + description?: string 137 + }
+1
shared/types/index.ts
··· 6 6 export * from './env' 7 7 export * from './deno-doc' 8 8 export * from './i18n-status' 9 + export * from './comparison'
+99
test/nuxt/components.spec.ts
··· 96 96 import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' 97 97 import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue' 98 98 import DependencyPathPopup from '~/components/DependencyPathPopup.vue' 99 + import CompareFacetSelector from '~/components/compare/FacetSelector.vue' 100 + import ComparePackageSelector from '~/components/compare/PackageSelector.vue' 101 + import CompareFacetRow from '~/components/compare/FacetRow.vue' 102 + import CompareComparisonGrid from '~/components/compare/ComparisonGrid.vue' 99 103 import PackageManagerSelect from '~/components/PackageManagerSelect.vue' 100 104 101 105 describe('component accessibility audits', () => { ··· 1300 1304 const component = await mountSuspended(DependencyPathPopup, { 1301 1305 props: { 1302 1306 path: ['root@1.0.0', 'dep-a@1.0.0', 'dep-b@2.0.0', 'dep-c@3.0.0', 'vulnerable-pkg@4.0.0'], 1307 + }, 1308 + }) 1309 + const results = await runAxe(component) 1310 + expect(results.violations).toEqual([]) 1311 + }) 1312 + }) 1313 + 1314 + // Compare feature components 1315 + describe('CompareFacetSelector', () => { 1316 + it('should have no accessibility violations', async () => { 1317 + const component = await mountSuspended(CompareFacetSelector) 1318 + const results = await runAxe(component) 1319 + expect(results.violations).toEqual([]) 1320 + }) 1321 + }) 1322 + 1323 + describe('ComparePackageSelector', () => { 1324 + it('should have no accessibility violations with no packages', async () => { 1325 + const component = await mountSuspended(ComparePackageSelector, { 1326 + props: { modelValue: [] }, 1327 + }) 1328 + const results = await runAxe(component) 1329 + expect(results.violations).toEqual([]) 1330 + }) 1331 + 1332 + it('should have no accessibility violations with packages selected', async () => { 1333 + const component = await mountSuspended(ComparePackageSelector, { 1334 + props: { modelValue: ['vue', 'react'] }, 1335 + }) 1336 + const results = await runAxe(component) 1337 + expect(results.violations).toEqual([]) 1338 + }) 1339 + 1340 + it('should have no accessibility violations at max packages', async () => { 1341 + const component = await mountSuspended(ComparePackageSelector, { 1342 + props: { modelValue: ['vue', 'react', 'angular', 'svelte'], max: 4 }, 1343 + }) 1344 + const results = await runAxe(component) 1345 + expect(results.violations).toEqual([]) 1346 + }) 1347 + }) 1348 + 1349 + describe('CompareFacetRow', () => { 1350 + it('should have no accessibility violations with basic values', async () => { 1351 + const component = await mountSuspended(CompareFacetRow, { 1352 + props: { 1353 + label: 'Downloads', 1354 + description: 'Weekly download count', 1355 + values: [ 1356 + { raw: 1000, display: '1,000' }, 1357 + { raw: 2000, display: '2,000' }, 1358 + ], 1359 + }, 1360 + }) 1361 + const results = await runAxe(component) 1362 + expect(results.violations).toEqual([]) 1363 + }) 1364 + 1365 + it('should have no accessibility violations when loading', async () => { 1366 + const component = await mountSuspended(CompareFacetRow, { 1367 + props: { 1368 + label: 'Install Size', 1369 + description: 'Total install size', 1370 + values: [null, null], 1371 + loading: true, 1372 + }, 1373 + }) 1374 + const results = await runAxe(component) 1375 + expect(results.violations).toEqual([]) 1376 + }) 1377 + }) 1378 + 1379 + describe('CompareComparisonGrid', () => { 1380 + it('should have no accessibility violations with 2 columns', async () => { 1381 + const component = await mountSuspended(CompareComparisonGrid, { 1382 + props: { 1383 + columns: 2, 1384 + headers: ['vue', 'react'], 1385 + }, 1386 + slots: { 1387 + default: '<div>Grid content</div>', 1388 + }, 1389 + }) 1390 + const results = await runAxe(component) 1391 + expect(results.violations).toEqual([]) 1392 + }) 1393 + 1394 + it('should have no accessibility violations with 3 columns', async () => { 1395 + const component = await mountSuspended(CompareComparisonGrid, { 1396 + props: { 1397 + columns: 3, 1398 + headers: ['vue', 'react', 'angular'], 1399 + }, 1400 + slots: { 1401 + default: '<div>Grid content</div>', 1303 1402 }, 1304 1403 }) 1305 1404 const results = await runAxe(component)
+121
test/nuxt/components/compare/ComparisonGrid.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import ComparisonGrid from '~/components/compare/ComparisonGrid.vue' 4 + 5 + describe('ComparisonGrid', () => { 6 + describe('header rendering', () => { 7 + it('renders column headers', async () => { 8 + const component = await mountSuspended(ComparisonGrid, { 9 + props: { 10 + columns: 2, 11 + headers: ['lodash@4.17.21', 'underscore@1.13.6'], 12 + }, 13 + }) 14 + expect(component.text()).toContain('lodash@4.17.21') 15 + expect(component.text()).toContain('underscore@1.13.6') 16 + }) 17 + 18 + it('renders correct number of header cells', async () => { 19 + const component = await mountSuspended(ComparisonGrid, { 20 + props: { 21 + columns: 3, 22 + headers: ['pkg1', 'pkg2', 'pkg3'], 23 + }, 24 + }) 25 + const headerCells = component.findAll('.comparison-cell-header') 26 + expect(headerCells.length).toBe(3) 27 + }) 28 + 29 + it('truncates long header text with title attribute', async () => { 30 + const longName = 'very-long-package-name@1.0.0-beta.1' 31 + const component = await mountSuspended(ComparisonGrid, { 32 + props: { 33 + columns: 2, 34 + headers: [longName, 'short'], 35 + }, 36 + }) 37 + const spans = component.findAll('.truncate') 38 + const longSpan = spans.find(s => s.text() === longName) 39 + expect(longSpan?.attributes('title')).toBe(longName) 40 + }) 41 + }) 42 + 43 + describe('column layout', () => { 44 + it('applies columns-2 class for 2 columns', async () => { 45 + const component = await mountSuspended(ComparisonGrid, { 46 + props: { 47 + columns: 2, 48 + headers: ['a', 'b'], 49 + }, 50 + }) 51 + expect(component.find('.columns-2').exists()).toBe(true) 52 + }) 53 + 54 + it('applies columns-3 class for 3 columns', async () => { 55 + const component = await mountSuspended(ComparisonGrid, { 56 + props: { 57 + columns: 3, 58 + headers: ['a', 'b', 'c'], 59 + }, 60 + }) 61 + expect(component.find('.columns-3').exists()).toBe(true) 62 + }) 63 + 64 + it('applies columns-4 class for 4 columns', async () => { 65 + const component = await mountSuspended(ComparisonGrid, { 66 + props: { 67 + columns: 4, 68 + headers: ['a', 'b', 'c', 'd'], 69 + }, 70 + }) 71 + expect(component.find('.columns-4').exists()).toBe(true) 72 + }) 73 + 74 + it('sets min-width for 4 columns to 800px', async () => { 75 + const component = await mountSuspended(ComparisonGrid, { 76 + props: { 77 + columns: 4, 78 + headers: ['a', 'b', 'c', 'd'], 79 + }, 80 + }) 81 + expect(component.find('.min-w-\\[800px\\]').exists()).toBe(true) 82 + }) 83 + 84 + it('sets min-width for 2-3 columns to 600px', async () => { 85 + const component = await mountSuspended(ComparisonGrid, { 86 + props: { 87 + columns: 2, 88 + headers: ['a', 'b'], 89 + }, 90 + }) 91 + expect(component.find('.min-w-\\[600px\\]').exists()).toBe(true) 92 + }) 93 + 94 + it('sets --columns CSS variable', async () => { 95 + const component = await mountSuspended(ComparisonGrid, { 96 + props: { 97 + columns: 3, 98 + headers: ['a', 'b', 'c'], 99 + }, 100 + }) 101 + const grid = component.find('.comparison-grid') 102 + expect(grid.attributes('style')).toContain('--columns: 3') 103 + }) 104 + }) 105 + 106 + describe('slot content', () => { 107 + it('renders default slot content', async () => { 108 + const component = await mountSuspended(ComparisonGrid, { 109 + props: { 110 + columns: 2, 111 + headers: ['a', 'b'], 112 + }, 113 + slots: { 114 + default: '<div class="test-row">Row content</div>', 115 + }, 116 + }) 117 + expect(component.find('.test-row').exists()).toBe(true) 118 + expect(component.text()).toContain('Row content') 119 + }) 120 + }) 121 + })
+234
test/nuxt/components/compare/FacetRow.spec.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import FacetRow from '~/components/compare/FacetRow.vue' 4 + 5 + // Mock useRelativeDates for DateTime component 6 + vi.mock('~/composables/useSettings', () => ({ 7 + useRelativeDates: () => ref(false), 8 + useSettings: () => ({ 9 + settings: ref({ relativeDates: false }), 10 + }), 11 + useAccentColor: () => ({}), 12 + initAccentOnPrehydrate: () => {}, 13 + })) 14 + 15 + describe('FacetRow', () => { 16 + const baseProps = { 17 + label: 'Downloads', 18 + values: [], 19 + } 20 + 21 + describe('label rendering', () => { 22 + it('renders the label', async () => { 23 + const component = await mountSuspended(FacetRow, { 24 + props: { ...baseProps, label: 'Weekly Downloads' }, 25 + }) 26 + expect(component.text()).toContain('Weekly Downloads') 27 + }) 28 + 29 + it('renders description tooltip icon when provided', async () => { 30 + const component = await mountSuspended(FacetRow, { 31 + props: { 32 + ...baseProps, 33 + description: 'Number of downloads per week', 34 + }, 35 + }) 36 + expect(component.find('.i-carbon\\:information').exists()).toBe(true) 37 + }) 38 + 39 + it('does not render description icon when not provided', async () => { 40 + const component = await mountSuspended(FacetRow, { 41 + props: baseProps, 42 + }) 43 + expect(component.find('.i-carbon\\:information').exists()).toBe(false) 44 + }) 45 + }) 46 + 47 + describe('value rendering', () => { 48 + it('renders null values as dash', async () => { 49 + const component = await mountSuspended(FacetRow, { 50 + props: { 51 + ...baseProps, 52 + values: [null, null], 53 + }, 54 + }) 55 + const cells = component.findAll('.comparison-cell') 56 + expect(cells.length).toBe(2) 57 + expect(component.text()).toContain('-') 58 + }) 59 + 60 + it('renders facet values', async () => { 61 + const component = await mountSuspended(FacetRow, { 62 + props: { 63 + ...baseProps, 64 + values: [ 65 + { raw: 1000, display: '1K', status: 'neutral' }, 66 + { raw: 2000, display: '2K', status: 'neutral' }, 67 + ], 68 + }, 69 + }) 70 + expect(component.text()).toContain('1K') 71 + expect(component.text()).toContain('2K') 72 + }) 73 + 74 + it('renders loading state for facet loading', async () => { 75 + const component = await mountSuspended(FacetRow, { 76 + props: { 77 + ...baseProps, 78 + values: [null, null], 79 + facetLoading: true, 80 + }, 81 + }) 82 + // All cells should show loading spinner 83 + expect(component.findAll('.i-carbon\\:circle-dash').length).toBe(2) 84 + }) 85 + 86 + it('renders loading state for specific column loading', async () => { 87 + const component = await mountSuspended(FacetRow, { 88 + props: { 89 + ...baseProps, 90 + values: [{ raw: 1000, display: '1K', status: 'neutral' }, null], 91 + columnLoading: [false, true], 92 + }, 93 + }) 94 + // Only second cell should show loading spinner 95 + const spinners = component.findAll('.i-carbon\\:circle-dash') 96 + expect(spinners.length).toBe(1) 97 + }) 98 + }) 99 + 100 + describe('status styling', () => { 101 + it('applies good status class', async () => { 102 + const component = await mountSuspended(FacetRow, { 103 + props: { 104 + ...baseProps, 105 + values: [{ raw: 0, display: 'None', status: 'good' }], 106 + }, 107 + }) 108 + expect(component.find('.text-emerald-400').exists()).toBe(true) 109 + }) 110 + 111 + it('applies warning status class', async () => { 112 + const component = await mountSuspended(FacetRow, { 113 + props: { 114 + ...baseProps, 115 + values: [{ raw: 100, display: '100 MB', status: 'warning' }], 116 + }, 117 + }) 118 + expect(component.find('.text-amber-400').exists()).toBe(true) 119 + }) 120 + 121 + it('applies bad status class', async () => { 122 + const component = await mountSuspended(FacetRow, { 123 + props: { 124 + ...baseProps, 125 + values: [{ raw: 5, display: '5 critical', status: 'bad' }], 126 + }, 127 + }) 128 + expect(component.find('.text-red-400').exists()).toBe(true) 129 + }) 130 + 131 + it('applies info status class', async () => { 132 + const component = await mountSuspended(FacetRow, { 133 + props: { 134 + ...baseProps, 135 + values: [{ raw: '@types', display: '@types', status: 'info' }], 136 + }, 137 + }) 138 + expect(component.find('.text-blue-400').exists()).toBe(true) 139 + }) 140 + }) 141 + 142 + describe('bar visualization', () => { 143 + it('shows bar for numeric values when bar prop is true', async () => { 144 + const component = await mountSuspended(FacetRow, { 145 + props: { 146 + ...baseProps, 147 + values: [ 148 + { raw: 100, display: '100', status: 'neutral' }, 149 + { raw: 200, display: '200', status: 'neutral' }, 150 + ], 151 + bar: true, 152 + }, 153 + }) 154 + // Bar elements have bg-fg/5 class 155 + expect(component.findAll('.bg-fg\\/5').length).toBeGreaterThan(0) 156 + }) 157 + 158 + it('hides bar when bar prop is false', async () => { 159 + const component = await mountSuspended(FacetRow, { 160 + props: { 161 + ...baseProps, 162 + values: [ 163 + { raw: 100, display: '100', status: 'neutral' }, 164 + { raw: 200, display: '200', status: 'neutral' }, 165 + ], 166 + bar: false, 167 + }, 168 + }) 169 + expect(component.findAll('.bg-fg\\/5').length).toBe(0) 170 + }) 171 + 172 + it('does not show bar for non-numeric values', async () => { 173 + const component = await mountSuspended(FacetRow, { 174 + props: { 175 + ...baseProps, 176 + values: [ 177 + { raw: 'MIT', display: 'MIT', status: 'neutral' }, 178 + { raw: 'Apache-2.0', display: 'Apache-2.0', status: 'neutral' }, 179 + ], 180 + }, 181 + }) 182 + expect(component.findAll('.bg-fg\\/5').length).toBe(0) 183 + }) 184 + }) 185 + 186 + describe('date values', () => { 187 + it('renders DateTime component for date type values', async () => { 188 + const component = await mountSuspended(FacetRow, { 189 + props: { 190 + ...baseProps, 191 + values: [ 192 + { 193 + raw: Date.now(), 194 + display: '2024-01-15T12:00:00.000Z', 195 + status: 'neutral', 196 + type: 'date', 197 + }, 198 + ], 199 + bar: false, // Disable bar for date values 200 + }, 201 + }) 202 + // DateTime component renders a time element 203 + expect(component.find('time').exists()).toBe(true) 204 + }) 205 + }) 206 + 207 + describe('grid layout', () => { 208 + it('uses contents display for grid integration', async () => { 209 + const component = await mountSuspended(FacetRow, { 210 + props: { 211 + ...baseProps, 212 + values: [{ raw: 100, display: '100', status: 'neutral' }], 213 + }, 214 + }) 215 + expect(component.find('.contents').exists()).toBe(true) 216 + }) 217 + 218 + it('renders correct number of cells for values', async () => { 219 + const component = await mountSuspended(FacetRow, { 220 + props: { 221 + ...baseProps, 222 + values: [ 223 + { raw: 1, display: '1', status: 'neutral' }, 224 + { raw: 2, display: '2', status: 'neutral' }, 225 + { raw: 3, display: '3', status: 'neutral' }, 226 + ], 227 + }, 228 + }) 229 + // 1 label cell + 3 value cells 230 + const cells = component.findAll('.comparison-cell') 231 + expect(cells.length).toBe(3) 232 + }) 233 + }) 234 + })
+239
test/nuxt/components/compare/FacetSelector.spec.ts
··· 1 + import type { ComparisonFacet } from '#shared/types/comparison' 2 + import { CATEGORY_ORDER, FACET_INFO, FACETS_BY_CATEGORY } from '#shared/types/comparison' 3 + import FacetSelector from '~/components/compare/FacetSelector.vue' 4 + import { beforeEach, describe, expect, it, vi } from 'vitest' 5 + import { ref } from 'vue' 6 + import { mountSuspended } from '@nuxt/test-utils/runtime' 7 + 8 + // Mock useFacetSelection 9 + const mockSelectedFacets = ref<string[]>(['downloads', 'types']) 10 + const mockIsFacetSelected = vi.fn((facet: string) => mockSelectedFacets.value.includes(facet)) 11 + const mockToggleFacet = vi.fn() 12 + const mockSelectCategory = vi.fn() 13 + const mockDeselectCategory = vi.fn() 14 + const mockSelectAll = vi.fn() 15 + const mockDeselectAll = vi.fn() 16 + const mockIsAllSelected = ref(false) 17 + const mockIsNoneSelected = ref(false) 18 + 19 + vi.mock('~/composables/useFacetSelection', () => ({ 20 + useFacetSelection: () => ({ 21 + selectedFacets: mockSelectedFacets, 22 + isFacetSelected: mockIsFacetSelected, 23 + toggleFacet: mockToggleFacet, 24 + selectCategory: mockSelectCategory, 25 + deselectCategory: mockDeselectCategory, 26 + selectAll: mockSelectAll, 27 + deselectAll: mockDeselectAll, 28 + isAllSelected: mockIsAllSelected, 29 + isNoneSelected: mockIsNoneSelected, 30 + }), 31 + })) 32 + 33 + // Mock useRouteQuery for composable 34 + vi.mock('@vueuse/router', () => ({ 35 + useRouteQuery: () => ref(''), 36 + })) 37 + 38 + describe('FacetSelector', () => { 39 + beforeEach(() => { 40 + mockSelectedFacets.value = ['downloads', 'types'] 41 + mockIsFacetSelected.mockImplementation((facet: string) => 42 + mockSelectedFacets.value.includes(facet), 43 + ) 44 + mockToggleFacet.mockClear() 45 + mockSelectCategory.mockClear() 46 + mockDeselectCategory.mockClear() 47 + mockSelectAll.mockClear() 48 + mockDeselectAll.mockClear() 49 + mockIsAllSelected.value = false 50 + mockIsNoneSelected.value = false 51 + }) 52 + 53 + describe('category rendering', () => { 54 + it('renders all categories', async () => { 55 + const component = await mountSuspended(FacetSelector) 56 + 57 + for (const category of CATEGORY_ORDER) { 58 + // Categories are rendered as uppercase text 59 + expect(component.text().toLowerCase()).toContain(category) 60 + } 61 + }) 62 + 63 + it('renders category headers with all/none buttons', async () => { 64 + const component = await mountSuspended(FacetSelector) 65 + 66 + // Each category has all/none buttons 67 + const allButtons = component.findAll('button').filter(b => b.text() === 'all') 68 + const noneButtons = component.findAll('button').filter(b => b.text() === 'none') 69 + 70 + // 4 categories = 4 all buttons + 4 none buttons 71 + expect(allButtons.length).toBe(4) 72 + expect(noneButtons.length).toBe(4) 73 + }) 74 + }) 75 + 76 + describe('facet buttons', () => { 77 + it('renders all facets from FACET_INFO', async () => { 78 + const component = await mountSuspended(FacetSelector) 79 + 80 + for (const facet of Object.keys(FACET_INFO)) { 81 + const facetInfo = FACET_INFO[facet as keyof typeof FACET_INFO] 82 + expect(component.text()).toContain(facetInfo.label) 83 + } 84 + }) 85 + 86 + it('shows checkmark icon for selected facets', async () => { 87 + mockSelectedFacets.value = ['downloads'] 88 + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') 89 + 90 + const component = await mountSuspended(FacetSelector) 91 + 92 + expect(component.find('.i-carbon\\:checkmark').exists()).toBe(true) 93 + }) 94 + 95 + it('shows add icon for unselected facets', async () => { 96 + mockSelectedFacets.value = ['downloads'] 97 + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') 98 + 99 + const component = await mountSuspended(FacetSelector) 100 + 101 + expect(component.find('.i-carbon\\:add').exists()).toBe(true) 102 + }) 103 + 104 + it('applies aria-pressed for selected state', async () => { 105 + mockSelectedFacets.value = ['downloads'] 106 + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') 107 + 108 + const component = await mountSuspended(FacetSelector) 109 + 110 + const buttons = component.findAll('button[aria-pressed]') 111 + const selectedButton = buttons.find(b => b.attributes('aria-pressed') === 'true') 112 + expect(selectedButton).toBeDefined() 113 + }) 114 + 115 + it('calls toggleFacet when facet button is clicked', async () => { 116 + const component = await mountSuspended(FacetSelector) 117 + 118 + // Find a facet button (not all/none) 119 + const facetButton = component.findAll('button').find(b => b.text().includes('Downloads')) 120 + await facetButton?.trigger('click') 121 + 122 + expect(mockToggleFacet).toHaveBeenCalled() 123 + }) 124 + }) 125 + 126 + describe('comingSoon facets', () => { 127 + it('disables comingSoon facets', async () => { 128 + const component = await mountSuspended(FacetSelector) 129 + 130 + // totalDependencies is marked as comingSoon 131 + const buttons = component.findAll('button') 132 + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) 133 + 134 + expect(comingSoonButton?.attributes('disabled')).toBeDefined() 135 + }) 136 + 137 + it('shows coming soon text for comingSoon facets', async () => { 138 + const component = await mountSuspended(FacetSelector) 139 + 140 + expect(component.text().toLowerCase()).toContain('coming soon') 141 + }) 142 + 143 + it('does not show checkmark/add icon for comingSoon facets', async () => { 144 + const component = await mountSuspended(FacetSelector) 145 + 146 + // Find the comingSoon button 147 + const buttons = component.findAll('button') 148 + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) 149 + 150 + // Should not have checkmark or add icon 151 + expect(comingSoonButton?.find('.i-carbon\\:checkmark').exists()).toBe(false) 152 + expect(comingSoonButton?.find('.i-carbon\\:add').exists()).toBe(false) 153 + }) 154 + 155 + it('does not call toggleFacet when comingSoon facet is clicked', async () => { 156 + const component = await mountSuspended(FacetSelector) 157 + 158 + const buttons = component.findAll('button') 159 + const comingSoonButton = buttons.find(b => b.text().includes('# Total Deps')) 160 + await comingSoonButton?.trigger('click') 161 + 162 + // toggleFacet should not have been called with totalDependencies 163 + expect(mockToggleFacet).not.toHaveBeenCalledWith('totalDependencies') 164 + }) 165 + }) 166 + 167 + describe('category all/none buttons', () => { 168 + it('calls selectCategory when all button is clicked', async () => { 169 + const component = await mountSuspended(FacetSelector) 170 + 171 + // Find the first 'all' button (for performance category) 172 + const allButton = component.findAll('button').find(b => b.text() === 'all') 173 + await allButton!.trigger('click') 174 + 175 + expect(mockSelectCategory).toHaveBeenCalledWith('performance') 176 + }) 177 + 178 + it('calls deselectCategory when none button is clicked', async () => { 179 + // Select a performance facet so 'none' button is enabled 180 + mockSelectedFacets.value = ['packageSize'] 181 + mockIsFacetSelected.mockImplementation((f: string) => f === 'packageSize') 182 + 183 + const component = await mountSuspended(FacetSelector) 184 + 185 + // Find the first 'none' button (for performance category) 186 + const noneButton = component.findAll('button').find(b => b.text() === 'none') 187 + await noneButton!.trigger('click') 188 + 189 + expect(mockDeselectCategory).toHaveBeenCalledWith('performance') 190 + }) 191 + 192 + it('disables all button when all facets in category are selected', async () => { 193 + // Select all performance facets 194 + const performanceFacets = FACETS_BY_CATEGORY.performance.filter( 195 + f => !FACET_INFO[f].comingSoon, 196 + ) 197 + mockSelectedFacets.value = performanceFacets 198 + mockIsFacetSelected.mockImplementation((f: string) => 199 + performanceFacets.includes(f as ComparisonFacet), 200 + ) 201 + 202 + const component = await mountSuspended(FacetSelector) 203 + 204 + const allButton = component.findAll('button').find(b => b.text() === 'all') 205 + // First all button (performance) should be disabled 206 + expect(allButton!.attributes('disabled')).toBeDefined() 207 + }) 208 + 209 + it('disables none button when no facets in category are selected', async () => { 210 + // Deselect all performance facets 211 + mockSelectedFacets.value = ['downloads'] // only health facet selected 212 + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') 213 + 214 + const component = await mountSuspended(FacetSelector) 215 + 216 + const noneButton = component.findAll('button').find(b => b.text() === 'none') 217 + // First none button (performance) should be disabled 218 + expect(noneButton!.attributes('disabled')).toBeDefined() 219 + }) 220 + }) 221 + 222 + describe('styling', () => { 223 + it('applies selected styling to selected facets', async () => { 224 + mockSelectedFacets.value = ['downloads'] 225 + mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads') 226 + 227 + const component = await mountSuspended(FacetSelector) 228 + 229 + // Selected facets have bg-bg-muted class 230 + expect(component.find('.bg-bg-muted').exists()).toBe(true) 231 + }) 232 + 233 + it('applies cursor-not-allowed to comingSoon facets', async () => { 234 + const component = await mountSuspended(FacetSelector) 235 + 236 + expect(component.find('.cursor-not-allowed').exists()).toBe(true) 237 + }) 238 + }) 239 + })
+244
test/nuxt/components/compare/PackageSelector.spec.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest' 2 + import { ref } from 'vue' 3 + import { mountSuspended } from '@nuxt/test-utils/runtime' 4 + import PackageSelector from '~/components/compare/PackageSelector.vue' 5 + 6 + // Mock $fetch for useNpmSearch 7 + const mockFetch = vi.fn() 8 + vi.stubGlobal('$fetch', mockFetch) 9 + 10 + describe('PackageSelector', () => { 11 + beforeEach(() => { 12 + mockFetch.mockReset() 13 + mockFetch.mockResolvedValue({ 14 + objects: [ 15 + { package: { name: 'lodash', description: 'Lodash modular utilities' } }, 16 + { package: { name: 'underscore', description: 'JavaScript utility library' } }, 17 + ], 18 + total: 2, 19 + time: new Date().toISOString(), 20 + }) 21 + }) 22 + 23 + describe('selected packages display', () => { 24 + it('renders selected packages as chips', async () => { 25 + const packages = ref(['lodash', 'underscore']) 26 + const component = await mountSuspended(PackageSelector, { 27 + props: { 28 + modelValue: packages.value, 29 + }, 30 + }) 31 + 32 + expect(component.text()).toContain('lodash') 33 + expect(component.text()).toContain('underscore') 34 + }) 35 + 36 + it('renders package names as links', async () => { 37 + const packages = ref(['lodash']) 38 + const component = await mountSuspended(PackageSelector, { 39 + props: { 40 + modelValue: packages.value, 41 + }, 42 + }) 43 + 44 + const link = component.find('a[href="/lodash"]') 45 + expect(link.exists()).toBe(true) 46 + }) 47 + 48 + it('renders remove button for each package', async () => { 49 + const packages = ref(['lodash', 'underscore']) 50 + const component = await mountSuspended(PackageSelector, { 51 + props: { 52 + modelValue: packages.value, 53 + }, 54 + }) 55 + 56 + const removeButtons = component 57 + .findAll('button') 58 + .filter(b => b.find('.i-carbon\\:close').exists()) 59 + expect(removeButtons.length).toBe(2) 60 + }) 61 + 62 + it('emits update when remove button is clicked', async () => { 63 + const component = await mountSuspended(PackageSelector, { 64 + props: { 65 + modelValue: ['lodash', 'underscore'], 66 + }, 67 + }) 68 + 69 + const removeButton = component 70 + .findAll('button') 71 + .find(b => b.find('.i-carbon\\:close').exists()) 72 + await removeButton!.trigger('click') 73 + 74 + const emitted = component.emitted('update:modelValue') 75 + expect(emitted).toBeTruthy() 76 + expect(emitted![0]![0]).toEqual(['underscore']) 77 + }) 78 + }) 79 + 80 + describe('search input', () => { 81 + it('renders search input when under max packages', async () => { 82 + const component = await mountSuspended(PackageSelector, { 83 + props: { 84 + modelValue: ['lodash'], 85 + max: 4, 86 + }, 87 + }) 88 + 89 + expect(component.find('input[type="text"]').exists()).toBe(true) 90 + }) 91 + 92 + it('hides search input when at max packages', async () => { 93 + const component = await mountSuspended(PackageSelector, { 94 + props: { 95 + modelValue: ['a', 'b', 'c', 'd'], 96 + max: 4, 97 + }, 98 + }) 99 + 100 + expect(component.find('input[type="text"]').exists()).toBe(false) 101 + }) 102 + 103 + it('shows different placeholder for first vs additional packages', async () => { 104 + // Empty state 105 + let component = await mountSuspended(PackageSelector, { 106 + props: { 107 + modelValue: [], 108 + }, 109 + }) 110 + let input = component.find('input') 111 + expect(input.attributes('placeholder')).toBeTruthy() 112 + 113 + // With packages 114 + component = await mountSuspended(PackageSelector, { 115 + props: { 116 + modelValue: ['lodash'], 117 + }, 118 + }) 119 + input = component.find('input') 120 + expect(input.attributes('placeholder')).toBeTruthy() 121 + }) 122 + 123 + it('has search icon', async () => { 124 + const component = await mountSuspended(PackageSelector, { 125 + props: { 126 + modelValue: [], 127 + }, 128 + }) 129 + 130 + expect(component.find('.i-carbon\\:search').exists()).toBe(true) 131 + }) 132 + }) 133 + 134 + describe('adding packages', () => { 135 + it('adds package on Enter key', async () => { 136 + const component = await mountSuspended(PackageSelector, { 137 + props: { 138 + modelValue: [], 139 + }, 140 + }) 141 + 142 + const input = component.find('input') 143 + await input.setValue('my-package') 144 + await input.trigger('keydown', { key: 'Enter' }) 145 + 146 + const emitted = component.emitted('update:modelValue') 147 + expect(emitted).toBeTruthy() 148 + expect(emitted![0]![0]).toEqual(['my-package']) 149 + }) 150 + 151 + it('clears input after adding package', async () => { 152 + const component = await mountSuspended(PackageSelector, { 153 + props: { 154 + modelValue: [], 155 + }, 156 + }) 157 + 158 + const input = component.find('input') 159 + await input.setValue('my-package') 160 + await input.trigger('keydown', { key: 'Enter' }) 161 + 162 + // Input should be cleared 163 + expect((input.element as HTMLInputElement).value).toBe('') 164 + }) 165 + 166 + it('does not add duplicate packages', async () => { 167 + const component = await mountSuspended(PackageSelector, { 168 + props: { 169 + modelValue: ['lodash'], 170 + }, 171 + }) 172 + 173 + const input = component.find('input') 174 + await input.setValue('lodash') 175 + await input.trigger('keydown', { key: 'Enter' }) 176 + 177 + const emitted = component.emitted('update:modelValue') 178 + // Should not emit since lodash is already selected 179 + expect(emitted).toBeFalsy() 180 + }) 181 + 182 + it('respects max packages limit', async () => { 183 + const component = await mountSuspended(PackageSelector, { 184 + props: { 185 + modelValue: ['a', 'b', 'c', 'd'], 186 + max: 4, 187 + }, 188 + }) 189 + 190 + // Input should not be visible 191 + expect(component.find('input').exists()).toBe(false) 192 + }) 193 + }) 194 + 195 + describe('hint text', () => { 196 + it('shows packages selected count', async () => { 197 + const component = await mountSuspended(PackageSelector, { 198 + props: { 199 + modelValue: ['lodash', 'underscore'], 200 + max: 4, 201 + }, 202 + }) 203 + 204 + expect(component.text()).toContain('2') 205 + expect(component.text()).toContain('4') 206 + }) 207 + 208 + it('shows add hint when less than 2 packages', async () => { 209 + const component = await mountSuspended(PackageSelector, { 210 + props: { 211 + modelValue: ['lodash'], 212 + max: 4, 213 + }, 214 + }) 215 + 216 + // Should have hint about adding more 217 + expect(component.text().toLowerCase()).toContain('add') 218 + }) 219 + }) 220 + 221 + describe('max prop', () => { 222 + it('defaults to 4 when not provided', async () => { 223 + const component = await mountSuspended(PackageSelector, { 224 + props: { 225 + modelValue: [], 226 + }, 227 + }) 228 + 229 + // Should show max of 4 in hint 230 + expect(component.text()).toContain('4') 231 + }) 232 + 233 + it('uses provided max value', async () => { 234 + const component = await mountSuspended(PackageSelector, { 235 + props: { 236 + modelValue: [], 237 + max: 3, 238 + }, 239 + }) 240 + 241 + expect(component.text()).toContain('3') 242 + }) 243 + }) 244 + })
+325
test/nuxt/composables/use-facet-selection.spec.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest' 2 + import { ref } from 'vue' 3 + import { DEFAULT_FACETS, FACETS_BY_CATEGORY } from '#shared/types/comparison' 4 + 5 + // Mock useRouteQuery 6 + const mockRouteQuery = ref('') 7 + vi.mock('@vueuse/router', () => ({ 8 + useRouteQuery: () => mockRouteQuery, 9 + })) 10 + 11 + describe('useFacetSelection', () => { 12 + beforeEach(() => { 13 + mockRouteQuery.value = '' 14 + }) 15 + 16 + it('returns DEFAULT_FACETS when no query param', () => { 17 + const { selectedFacets } = useFacetSelection() 18 + 19 + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 20 + }) 21 + 22 + it('parses facets from query param', () => { 23 + mockRouteQuery.value = 'downloads,types,license' 24 + 25 + const { selectedFacets } = useFacetSelection() 26 + 27 + expect(selectedFacets.value).toContain('downloads') 28 + expect(selectedFacets.value).toContain('types') 29 + expect(selectedFacets.value).toContain('license') 30 + }) 31 + 32 + it('filters out invalid facets from query', () => { 33 + mockRouteQuery.value = 'downloads,invalidFacet,types' 34 + 35 + const { selectedFacets } = useFacetSelection() 36 + 37 + expect(selectedFacets.value).toContain('downloads') 38 + expect(selectedFacets.value).toContain('types') 39 + expect(selectedFacets.value).not.toContain('invalidFacet') 40 + }) 41 + 42 + it('filters out comingSoon facets from query', () => { 43 + mockRouteQuery.value = 'downloads,totalDependencies,types' 44 + 45 + const { selectedFacets } = useFacetSelection() 46 + 47 + expect(selectedFacets.value).toContain('downloads') 48 + expect(selectedFacets.value).toContain('types') 49 + expect(selectedFacets.value).not.toContain('totalDependencies') 50 + }) 51 + 52 + it('falls back to DEFAULT_FACETS if all parsed facets are invalid', () => { 53 + mockRouteQuery.value = 'invalidFacet1,invalidFacet2' 54 + 55 + const { selectedFacets } = useFacetSelection() 56 + 57 + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 58 + }) 59 + 60 + describe('isFacetSelected', () => { 61 + it('returns true for selected facets', () => { 62 + mockRouteQuery.value = 'downloads,types' 63 + 64 + const { isFacetSelected } = useFacetSelection() 65 + 66 + expect(isFacetSelected('downloads')).toBe(true) 67 + expect(isFacetSelected('types')).toBe(true) 68 + }) 69 + 70 + it('returns false for unselected facets', () => { 71 + mockRouteQuery.value = 'downloads,types' 72 + 73 + const { isFacetSelected } = useFacetSelection() 74 + 75 + expect(isFacetSelected('license')).toBe(false) 76 + expect(isFacetSelected('engines')).toBe(false) 77 + }) 78 + }) 79 + 80 + describe('toggleFacet', () => { 81 + it('adds facet when not selected', () => { 82 + mockRouteQuery.value = 'downloads' 83 + 84 + const { selectedFacets, toggleFacet } = useFacetSelection() 85 + 86 + toggleFacet('types') 87 + 88 + expect(selectedFacets.value).toContain('downloads') 89 + expect(selectedFacets.value).toContain('types') 90 + }) 91 + 92 + it('removes facet when selected', () => { 93 + mockRouteQuery.value = 'downloads,types' 94 + 95 + const { selectedFacets, toggleFacet } = useFacetSelection() 96 + 97 + toggleFacet('types') 98 + 99 + expect(selectedFacets.value).toContain('downloads') 100 + expect(selectedFacets.value).not.toContain('types') 101 + }) 102 + 103 + it('does not remove last facet', () => { 104 + mockRouteQuery.value = 'downloads' 105 + 106 + const { selectedFacets, toggleFacet } = useFacetSelection() 107 + 108 + toggleFacet('downloads') 109 + 110 + expect(selectedFacets.value).toContain('downloads') 111 + expect(selectedFacets.value.length).toBe(1) 112 + }) 113 + }) 114 + 115 + describe('selectCategory', () => { 116 + it('selects all facets in a category', () => { 117 + mockRouteQuery.value = 'downloads' 118 + 119 + const { selectedFacets, selectCategory } = useFacetSelection() 120 + 121 + selectCategory('performance') 122 + 123 + const performanceFacets = FACETS_BY_CATEGORY.performance.filter( 124 + f => f !== 'totalDependencies', // comingSoon facet 125 + ) 126 + for (const facet of performanceFacets) { 127 + expect(selectedFacets.value).toContain(facet) 128 + } 129 + }) 130 + 131 + it('preserves existing selections from other categories', () => { 132 + mockRouteQuery.value = 'downloads,license' 133 + 134 + const { selectedFacets, selectCategory } = useFacetSelection() 135 + 136 + selectCategory('compatibility') 137 + 138 + expect(selectedFacets.value).toContain('downloads') 139 + expect(selectedFacets.value).toContain('license') 140 + }) 141 + }) 142 + 143 + describe('deselectCategory', () => { 144 + it('deselects all facets in a category', () => { 145 + mockRouteQuery.value = '' 146 + const { selectedFacets, deselectCategory } = useFacetSelection() 147 + 148 + deselectCategory('performance') 149 + 150 + const nonComingSoonPerformanceFacets = FACETS_BY_CATEGORY.performance.filter( 151 + f => f !== 'totalDependencies', 152 + ) 153 + for (const facet of nonComingSoonPerformanceFacets) { 154 + expect(selectedFacets.value).not.toContain(facet) 155 + } 156 + }) 157 + 158 + it('does not deselect if it would leave no facets', () => { 159 + mockRouteQuery.value = 'packageSize,installSize' 160 + 161 + const { selectedFacets, deselectCategory } = useFacetSelection() 162 + 163 + deselectCategory('performance') 164 + 165 + // Should still have at least one facet 166 + expect(selectedFacets.value.length).toBeGreaterThan(0) 167 + }) 168 + }) 169 + 170 + describe('selectAll', () => { 171 + it('selects all default facets', () => { 172 + mockRouteQuery.value = 'downloads' 173 + 174 + const { selectedFacets, selectAll } = useFacetSelection() 175 + 176 + selectAll() 177 + 178 + expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 179 + }) 180 + }) 181 + 182 + describe('deselectAll', () => { 183 + it('keeps only the first default facet', () => { 184 + mockRouteQuery.value = '' 185 + 186 + const { selectedFacets, deselectAll } = useFacetSelection() 187 + 188 + deselectAll() 189 + 190 + expect(selectedFacets.value).toHaveLength(1) 191 + expect(selectedFacets.value[0]).toBe(DEFAULT_FACETS[0]) 192 + }) 193 + }) 194 + 195 + describe('isAllSelected', () => { 196 + it('returns true when all facets selected', () => { 197 + mockRouteQuery.value = '' 198 + 199 + const { isAllSelected } = useFacetSelection() 200 + 201 + expect(isAllSelected.value).toBe(true) 202 + }) 203 + 204 + it('returns false when not all facets selected', () => { 205 + mockRouteQuery.value = 'downloads,types' 206 + 207 + const { isAllSelected } = useFacetSelection() 208 + 209 + expect(isAllSelected.value).toBe(false) 210 + }) 211 + }) 212 + 213 + describe('isNoneSelected', () => { 214 + it('returns true when only one facet selected', () => { 215 + mockRouteQuery.value = 'downloads' 216 + 217 + const { isNoneSelected } = useFacetSelection() 218 + 219 + expect(isNoneSelected.value).toBe(true) 220 + }) 221 + 222 + it('returns false when multiple facets selected', () => { 223 + mockRouteQuery.value = 'downloads,types' 224 + 225 + const { isNoneSelected } = useFacetSelection() 226 + 227 + expect(isNoneSelected.value).toBe(false) 228 + }) 229 + }) 230 + 231 + describe('URL param behavior', () => { 232 + it('clears URL param when selecting all defaults', () => { 233 + mockRouteQuery.value = 'downloads,types' 234 + 235 + const { selectAll } = useFacetSelection() 236 + 237 + selectAll() 238 + 239 + // Should clear to empty string when matching defaults 240 + expect(mockRouteQuery.value).toBe('') 241 + }) 242 + 243 + it('sets URL param when selecting subset of facets', () => { 244 + mockRouteQuery.value = '' 245 + 246 + const { selectedFacets } = useFacetSelection() 247 + 248 + selectedFacets.value = ['downloads', 'types'] 249 + 250 + expect(mockRouteQuery.value).toBe('downloads,types') 251 + }) 252 + }) 253 + 254 + describe('allFacets export', () => { 255 + it('exports allFacets array', () => { 256 + const { allFacets } = useFacetSelection() 257 + 258 + expect(Array.isArray(allFacets)).toBe(true) 259 + expect(allFacets.length).toBeGreaterThan(0) 260 + }) 261 + 262 + it('allFacets includes all facets including comingSoon', () => { 263 + const { allFacets } = useFacetSelection() 264 + 265 + expect(allFacets).toContain('totalDependencies') 266 + }) 267 + }) 268 + 269 + describe('whitespace handling', () => { 270 + it('trims whitespace from facet names in query', () => { 271 + mockRouteQuery.value = ' downloads , types , license ' 272 + 273 + const { selectedFacets } = useFacetSelection() 274 + 275 + expect(selectedFacets.value).toContain('downloads') 276 + expect(selectedFacets.value).toContain('types') 277 + expect(selectedFacets.value).toContain('license') 278 + }) 279 + }) 280 + 281 + describe('duplicate handling', () => { 282 + it('handles duplicate facets in query by deduplication via Set', () => { 283 + // When adding facets, the code uses Set for deduplication 284 + mockRouteQuery.value = 'downloads' 285 + 286 + const { selectedFacets, selectCategory } = useFacetSelection() 287 + 288 + // downloads is in health category, selecting health should dedupe 289 + selectCategory('health') 290 + 291 + // Count occurrences of downloads 292 + const downloadsCount = selectedFacets.value.filter(f => f === 'downloads').length 293 + expect(downloadsCount).toBe(1) 294 + }) 295 + }) 296 + 297 + describe('multiple category operations', () => { 298 + it('can select multiple categories', () => { 299 + mockRouteQuery.value = 'downloads' 300 + 301 + const { selectedFacets, selectCategory } = useFacetSelection() 302 + 303 + selectCategory('performance') 304 + selectCategory('security') 305 + 306 + // Should have facets from both categories plus original 307 + expect(selectedFacets.value).toContain('packageSize') 308 + expect(selectedFacets.value).toContain('license') 309 + expect(selectedFacets.value).toContain('downloads') 310 + }) 311 + 312 + it('can deselect multiple categories', () => { 313 + mockRouteQuery.value = '' 314 + 315 + const { selectedFacets, deselectCategory } = useFacetSelection() 316 + 317 + deselectCategory('performance') 318 + deselectCategory('health') 319 + 320 + // Should not have performance or health facets 321 + expect(selectedFacets.value).not.toContain('packageSize') 322 + expect(selectedFacets.value).not.toContain('downloads') 323 + }) 324 + }) 325 + })