[READ-ONLY] a fast, modern browser for the npm registry
at main 235 lines 7.7 kB view raw
1import type { ComparisonFacet, FacetInfo } from '#shared/types' 2import { 3 ALL_FACETS, 4 CATEGORY_ORDER, 5 DEFAULT_FACETS, 6 FACET_INFO, 7 FACETS_BY_CATEGORY, 8} from '#shared/types/comparison' 9import { useRouteQuery } from '@vueuse/router' 10 11/** Facet info enriched with i18n labels */ 12export interface FacetInfoWithLabels extends Omit<FacetInfo, 'id'> { 13 id: ComparisonFacet 14 label: string 15 description: string 16} 17 18/** 19 * Composable for managing comparison facet selection with URL sync. 20 * 21 * @param queryParam - The URL query parameter name to use (default: 'facets') 22 */ 23export function useFacetSelection(queryParam = 'facets') { 24 const { t } = useI18n() 25 26 const facetLabels = computed( 27 (): Record<ComparisonFacet, { label: string; description: string }> => ({ 28 downloads: { 29 label: t(`compare.facets.items.downloads.label`), 30 description: t(`compare.facets.items.downloads.description`), 31 }, 32 totalLikes: { 33 label: t(`compare.facets.items.totalLikes.label`), 34 description: t(`compare.facets.items.totalLikes.description`), 35 }, 36 packageSize: { 37 label: t(`compare.facets.items.packageSize.label`), 38 description: t(`compare.facets.items.packageSize.description`), 39 }, 40 installSize: { 41 label: t(`compare.facets.items.installSize.label`), 42 description: t(`compare.facets.items.installSize.description`), 43 }, 44 moduleFormat: { 45 label: t(`compare.facets.items.moduleFormat.label`), 46 description: t(`compare.facets.items.moduleFormat.description`), 47 }, 48 types: { 49 label: t(`compare.facets.items.types.label`), 50 description: t(`compare.facets.items.types.description`), 51 }, 52 engines: { 53 label: t(`compare.facets.items.engines.label`), 54 description: t(`compare.facets.items.engines.description`), 55 }, 56 vulnerabilities: { 57 label: t(`compare.facets.items.vulnerabilities.label`), 58 description: t(`compare.facets.items.vulnerabilities.description`), 59 }, 60 lastUpdated: { 61 label: t(`compare.facets.items.lastUpdated.label`), 62 description: t(`compare.facets.items.lastUpdated.description`), 63 }, 64 license: { 65 label: t(`compare.facets.items.license.label`), 66 description: t(`compare.facets.items.license.description`), 67 }, 68 dependencies: { 69 label: t(`compare.facets.items.dependencies.label`), 70 description: t(`compare.facets.items.dependencies.description`), 71 }, 72 totalDependencies: { 73 label: t(`compare.facets.items.totalDependencies.label`), 74 description: t(`compare.facets.items.totalDependencies.description`), 75 }, 76 deprecated: { 77 label: t(`compare.facets.items.deprecated.label`), 78 description: t(`compare.facets.items.deprecated.description`), 79 }, 80 }), 81 ) 82 83 // Helper to build facet info with i18n labels 84 function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels { 85 return { 86 id: facet, 87 ...FACET_INFO[facet], 88 label: facetLabels.value[facet].label, 89 description: facetLabels.value[facet].description, 90 } 91 } 92 93 // Sync with URL query param (stable ref - doesn't change on other query changes) 94 const facetsParam = useRouteQuery<string>(queryParam, '', { mode: 'replace' }) 95 96 // Parse facet IDs from URL or use defaults 97 const selectedFacetIds = computed<ComparisonFacet[]>({ 98 get() { 99 if (!facetsParam.value) { 100 return DEFAULT_FACETS 101 } 102 103 // Parse comma-separated facets and filter valid, non-comingSoon ones 104 const parsed = facetsParam.value 105 .split(',') 106 .map(f => f.trim()) 107 .filter( 108 (f): f is ComparisonFacet => 109 ALL_FACETS.includes(f as ComparisonFacet) && 110 !FACET_INFO[f as ComparisonFacet].comingSoon, 111 ) 112 113 return parsed.length > 0 ? parsed : DEFAULT_FACETS 114 }, 115 set(facets) { 116 if (facets.length === 0 || arraysEqual(facets, DEFAULT_FACETS)) { 117 // Remove param if using defaults 118 facetsParam.value = '' 119 } else { 120 facetsParam.value = facets.join(',') 121 } 122 }, 123 }) 124 125 // Selected facets with full info and i18n labels 126 const selectedFacets = computed<FacetInfoWithLabels[]>(() => 127 selectedFacetIds.value.map(buildFacetInfo), 128 ) 129 130 // Check if a facet is selected 131 function isFacetSelected(facet: ComparisonFacet): boolean { 132 return selectedFacetIds.value.includes(facet) 133 } 134 135 // Toggle a single facet 136 function toggleFacet(facet: ComparisonFacet): void { 137 const current = selectedFacetIds.value 138 if (current.includes(facet)) { 139 // Don't allow deselecting all facets 140 if (current.length > 1) { 141 selectedFacetIds.value = current.filter(f => f !== facet) 142 } 143 } else { 144 selectedFacetIds.value = [...current, facet] 145 } 146 } 147 148 // Get facets in a category (excluding coming soon) 149 function getFacetsInCategory(category: string): ComparisonFacet[] { 150 return ALL_FACETS.filter(f => { 151 const info = FACET_INFO[f] 152 return info.category === category && !info.comingSoon 153 }) 154 } 155 156 // Select all facets in a category 157 function selectCategory(category: string): void { 158 const categoryFacets = getFacetsInCategory(category) 159 const current = selectedFacetIds.value 160 const newFacets = [...new Set([...current, ...categoryFacets])] 161 selectedFacetIds.value = newFacets 162 } 163 164 // Deselect all facets in a category 165 function deselectCategory(category: string): void { 166 const categoryFacets = getFacetsInCategory(category) 167 const remaining = selectedFacetIds.value.filter(f => !categoryFacets.includes(f)) 168 // Don't allow deselecting all facets 169 if (remaining.length > 0) { 170 selectedFacetIds.value = remaining 171 } 172 } 173 174 // Select all facets globally 175 function selectAll(): void { 176 selectedFacetIds.value = DEFAULT_FACETS 177 } 178 179 // Deselect all facets globally (keeps first facet to ensure at least one) 180 function deselectAll(): void { 181 selectedFacetIds.value = [DEFAULT_FACETS[0] as ComparisonFacet] 182 } 183 184 // Check if all facets are selected 185 const isAllSelected = computed(() => selectedFacetIds.value.length === DEFAULT_FACETS.length) 186 187 // Check if only one facet is selected (minimum) 188 const isNoneSelected = computed(() => selectedFacetIds.value.length === 1) 189 190 const facetCategories = { 191 performance: t(`compare.facets.categories.performance`), 192 health: t(`compare.facets.categories.health`), 193 compatibility: t(`compare.facets.categories.compatibility`), 194 security: t(`compare.facets.categories.security`), 195 } 196 197 // Get translated category name 198 function getCategoryLabel(category: FacetInfo['category']): string { 199 return facetCategories[category] 200 } 201 202 // All facets with their info and i18n labels, grouped by category 203 const facetsByCategory = computed(() => { 204 const result: Record<string, FacetInfoWithLabels[]> = {} 205 for (const category of CATEGORY_ORDER) { 206 result[category] = FACETS_BY_CATEGORY[category].map(buildFacetInfo) 207 } 208 return result 209 }) 210 211 return { 212 selectedFacets, 213 isFacetSelected, 214 toggleFacet, 215 selectCategory, 216 deselectCategory, 217 selectAll, 218 deselectAll, 219 isAllSelected, 220 isNoneSelected, 221 allFacets: ALL_FACETS, 222 // Facet info with i18n 223 getCategoryLabel, 224 facetsByCategory, 225 categoryOrder: CATEGORY_ORDER, 226 } 227} 228 229// Helper to compare arrays 230function arraysEqual<T>(a: T[], b: T[]): boolean { 231 if (a.length !== b.length) return false 232 const sortedA = [...a].sort() 233 const sortedB = [...b].sort() 234 return sortedA.every((val, i) => val === sortedB[i]) 235}