forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}