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

feat: add i18n support to compare page (#569)

Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Wojciech Maj
Daniel Roe
autofix-ci[bot]
and committed by
GitHub
46fd476b 0afd38b0

+853 -410
+14 -14
app/components/Filter/Panel.vue
··· 8 8 } from '#shared/types/preferences' 9 9 import { 10 10 DOWNLOAD_RANGES, 11 - SEARCH_SCOPE_OPTIONS, 12 - SECURITY_FILTER_OPTIONS, 11 + SEARCH_SCOPE_VALUES, 12 + SECURITY_FILTER_VALUES, 13 13 UPDATED_WITHIN_OPTIONS, 14 14 } from '#shared/types/preferences' 15 15 ··· 205 205 :aria-label="$t('filters.search_scope')" 206 206 > 207 207 <button 208 - v-for="option in SEARCH_SCOPE_OPTIONS" 209 - :key="option.value" 208 + v-for="scope in SEARCH_SCOPE_VALUES" 209 + :key="scope" 210 210 type="button" 211 211 class="px-2 py-0.5 text-xs font-mono rounded-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 212 212 :class=" 213 - filters.searchScope === option.value 213 + filters.searchScope === scope 214 214 ? 'bg-bg-muted text-fg' 215 215 : 'text-fg-muted hover:text-fg' 216 216 " 217 - :aria-pressed="filters.searchScope === option.value" 218 - :title="$t(getScopeDescriptionKey(option.value))" 219 - @click="emit('update:searchScope', option.value)" 217 + :aria-pressed="filters.searchScope === scope" 218 + :title="$t(getScopeDescriptionKey(scope))" 219 + @click="emit('update:searchScope', scope)" 220 220 > 221 - {{ $t(getScopeLabelKey(option.value)) }} 221 + {{ $t(getScopeLabelKey(scope)) }} 222 222 </button> 223 223 </div> 224 224 </div> ··· 301 301 </legend> 302 302 <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')"> 303 303 <button 304 - v-for="option in SECURITY_FILTER_OPTIONS" 305 - :key="option.value" 304 + v-for="security in SECURITY_FILTER_VALUES" 305 + :key="security" 306 306 type="button" 307 307 role="radio" 308 308 disabled 309 - :aria-checked="filters.security === option.value" 309 + :aria-checked="filters.security === security" 310 310 class="tag transition-colors duration-200 opacity-50 cursor-not-allowed focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 311 311 :class=" 312 - filters.security === option.value ? 'bg-fg text-bg border-fg hover:text-bg/70' : '' 312 + filters.security === security ? 'bg-fg text-bg border-fg hover:text-bg/70' : '' 313 313 " 314 314 > 315 - {{ $t(getSecurityLabelKey(option.value)) }} 315 + {{ $t(getSecurityLabelKey(security)) }} 316 316 </button> 317 317 </div> 318 318 </fieldset>
+30 -44
app/components/compare/FacetSelector.vue
··· 1 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 { isFacetSelected, toggleFacet, selectCategory, deselectCategory } = useFacetSelection() 6 - 7 - // Enrich facets with their info for rendering 8 - const facetsByCategory = computed(() => { 9 - const result: Record< 10 - string, 11 - { facet: ComparisonFacet; info: (typeof FACET_INFO)[ComparisonFacet] }[] 12 - > = {} 13 - for (const category of CATEGORY_ORDER) { 14 - result[category] = FACETS_BY_CATEGORY[category].map(facet => ({ 15 - facet, 16 - info: FACET_INFO[facet], 17 - })) 18 - } 19 - return result 20 - }) 2 + const { 3 + isFacetSelected, 4 + toggleFacet, 5 + selectCategory, 6 + deselectCategory, 7 + facetsByCategory, 8 + categoryOrder, 9 + getCategoryLabel, 10 + } = useFacetSelection() 21 11 22 12 // Check if all non-comingSoon facets in a category are selected 23 13 function isCategoryAllSelected(category: string): boolean { 24 14 const facets = facetsByCategory.value[category] ?? [] 25 - const selectableFacets = facets.filter(f => !f.info.comingSoon) 26 - return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet)) 15 + const selectableFacets = facets.filter(f => !f.comingSoon) 16 + return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.id)) 27 17 } 28 18 29 19 // Check if no facets in a category are selected 30 20 function isCategoryNoneSelected(category: string): boolean { 31 21 const facets = facetsByCategory.value[category] ?? [] 32 - const selectableFacets = facets.filter(f => !f.info.comingSoon) 33 - return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet)) 22 + const selectableFacets = facets.filter(f => !f.comingSoon) 23 + return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.id)) 34 24 } 35 25 </script> 36 26 37 27 <template> 38 28 <div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')"> 39 - <div v-for="category in CATEGORY_ORDER" :key="category"> 29 + <div v-for="category in categoryOrder" :key="category"> 40 30 <!-- Category header with all/none buttons --> 41 31 <div class="flex items-center gap-2 mb-2"> 42 32 <span class="text-[10px] text-fg-subtle uppercase tracking-wider"> 43 - {{ $t(`compare.facets.categories.${category}`) }} 33 + {{ getCategoryLabel(category) }} 44 34 </span> 45 35 <button 46 36 type="button" ··· 51 41 : 'text-fg-muted/60 hover:text-fg-muted' 52 42 " 53 43 :aria-label=" 54 - $t('compare.facets.select_category', { 55 - category: $t(`compare.facets.categories.${category}`), 56 - }) 44 + $t('compare.facets.select_category', { category: getCategoryLabel(category) }) 57 45 " 58 46 :disabled="isCategoryAllSelected(category)" 59 47 @click="selectCategory(category)" ··· 70 58 : 'text-fg-muted/60 hover:text-fg-muted' 71 59 " 72 60 :aria-label=" 73 - $t('compare.facets.deselect_category', { 74 - category: $t(`compare.facets.categories.${category}`), 75 - }) 61 + $t('compare.facets.deselect_category', { category: getCategoryLabel(category) }) 76 62 " 77 63 :disabled="isCategoryNoneSelected(category)" 78 64 @click="deselectCategory(category)" ··· 84 70 <!-- Facet buttons --> 85 71 <div class="flex items-center gap-1.5 flex-wrap" role="group"> 86 72 <button 87 - v-for="{ facet, info } in facetsByCategory[category]" 88 - :key="facet" 73 + v-for="facet in facetsByCategory[category]" 74 + :key="facet.id" 89 75 type="button" 90 - :title="info.comingSoon ? $t('compare.facets.coming_soon') : info.description" 91 - :disabled="info.comingSoon" 92 - :aria-pressed="isFacetSelected(facet)" 93 - :aria-label="info.label" 76 + :title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description" 77 + :disabled="facet.comingSoon" 78 + :aria-pressed="isFacetSelected(facet.id)" 79 + :aria-label="facet.label" 94 80 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" 95 81 :class=" 96 - info.comingSoon 82 + facet.comingSoon 97 83 ? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed' 98 - : isFacetSelected(facet) 84 + : isFacetSelected(facet.id) 99 85 ? 'text-fg-muted bg-bg-muted border-border' 100 86 : 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border' 101 87 " 102 - @click="!info.comingSoon && toggleFacet(facet)" 88 + @click="!facet.comingSoon && toggleFacet(facet.id)" 103 89 > 104 90 <span 105 - v-if="!info.comingSoon" 91 + v-if="!facet.comingSoon" 106 92 class="w-3 h-3" 107 - :class="isFacetSelected(facet) ? 'i-carbon:checkmark' : 'i-carbon:add'" 93 + :class="isFacetSelected(facet.id) ? 'i-carbon:checkmark' : 'i-carbon:add'" 108 94 aria-hidden="true" 109 95 /> 110 - {{ info.label }} 111 - <span v-if="info.comingSoon" class="text-[9px]" 96 + {{ facet.label }} 97 + <span v-if="facet.comingSoon" class="text-[9px]" 112 98 >({{ $t('compare.facets.coming_soon') }})</span 113 99 > 114 100 </button>
+64 -16
app/composables/useFacetSelection.ts
··· 1 - import type { ComparisonFacet } from '#shared/types' 2 - import { ALL_FACETS, DEFAULT_FACETS, FACET_INFO } from '#shared/types/comparison' 1 + import type { ComparisonFacet, FacetInfo } from '#shared/types' 2 + import { 3 + ALL_FACETS, 4 + CATEGORY_ORDER, 5 + DEFAULT_FACETS, 6 + FACET_INFO, 7 + FACETS_BY_CATEGORY, 8 + } from '#shared/types/comparison' 3 9 import { useRouteQuery } from '@vueuse/router' 4 10 11 + /** Facet info enriched with i18n labels */ 12 + export interface FacetInfoWithLabels extends Omit<FacetInfo, 'id'> { 13 + id: ComparisonFacet 14 + label: string 15 + description: string 16 + } 17 + 5 18 /** 6 19 * Composable for managing comparison facet selection with URL sync. 7 20 * 8 21 * @param queryParam - The URL query parameter name to use (default: 'facets') 9 22 */ 10 23 export function useFacetSelection(queryParam = 'facets') { 24 + const { t } = useI18n() 25 + 26 + // Helper to build facet info with i18n labels 27 + function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels { 28 + return { 29 + id: facet, 30 + ...FACET_INFO[facet], 31 + label: t(`compare.facets.items.${facet}.label`), 32 + description: t(`compare.facets.items.${facet}.description`), 33 + } 34 + } 35 + 11 36 // Sync with URL query param (stable ref - doesn't change on other query changes) 12 37 const facetsParam = useRouteQuery<string>(queryParam, '', { mode: 'replace' }) 13 38 14 - // Parse facets from URL or use defaults 15 - const selectedFacets = computed<ComparisonFacet[]>({ 39 + // Parse facet IDs from URL or use defaults 40 + const selectedFacetIds = computed<ComparisonFacet[]>({ 16 41 get() { 17 42 if (!facetsParam.value) { 18 43 return DEFAULT_FACETS ··· 40 65 }, 41 66 }) 42 67 68 + // Selected facets with full info and i18n labels 69 + const selectedFacets = computed<FacetInfoWithLabels[]>(() => 70 + selectedFacetIds.value.map(buildFacetInfo), 71 + ) 72 + 43 73 // Check if a facet is selected 44 74 function isFacetSelected(facet: ComparisonFacet): boolean { 45 - return selectedFacets.value.includes(facet) 75 + return selectedFacetIds.value.includes(facet) 46 76 } 47 77 48 78 // Toggle a single facet 49 79 function toggleFacet(facet: ComparisonFacet): void { 50 - const current = selectedFacets.value 80 + const current = selectedFacetIds.value 51 81 if (current.includes(facet)) { 52 82 // Don't allow deselecting all facets 53 83 if (current.length > 1) { 54 - selectedFacets.value = current.filter(f => f !== facet) 84 + selectedFacetIds.value = current.filter(f => f !== facet) 55 85 } 56 86 } else { 57 - selectedFacets.value = [...current, facet] 87 + selectedFacetIds.value = [...current, facet] 58 88 } 59 89 } 60 90 ··· 69 99 // Select all facets in a category 70 100 function selectCategory(category: string): void { 71 101 const categoryFacets = getFacetsInCategory(category) 72 - const current = selectedFacets.value 102 + const current = selectedFacetIds.value 73 103 const newFacets = [...new Set([...current, ...categoryFacets])] 74 - selectedFacets.value = newFacets 104 + selectedFacetIds.value = newFacets 75 105 } 76 106 77 107 // Deselect all facets in a category 78 108 function deselectCategory(category: string): void { 79 109 const categoryFacets = getFacetsInCategory(category) 80 - const remaining = selectedFacets.value.filter(f => !categoryFacets.includes(f)) 110 + const remaining = selectedFacetIds.value.filter(f => !categoryFacets.includes(f)) 81 111 // Don't allow deselecting all facets 82 112 if (remaining.length > 0) { 83 - selectedFacets.value = remaining 113 + selectedFacetIds.value = remaining 84 114 } 85 115 } 86 116 87 117 // Select all facets globally 88 118 function selectAll(): void { 89 - selectedFacets.value = DEFAULT_FACETS 119 + selectedFacetIds.value = DEFAULT_FACETS 90 120 } 91 121 92 122 // Deselect all facets globally (keeps first facet to ensure at least one) 93 123 function deselectAll(): void { 94 - selectedFacets.value = [DEFAULT_FACETS[0] as ComparisonFacet] 124 + selectedFacetIds.value = [DEFAULT_FACETS[0] as ComparisonFacet] 95 125 } 96 126 97 127 // Check if all facets are selected 98 - const isAllSelected = computed(() => selectedFacets.value.length === DEFAULT_FACETS.length) 128 + const isAllSelected = computed(() => selectedFacetIds.value.length === DEFAULT_FACETS.length) 99 129 100 130 // Check if only one facet is selected (minimum) 101 - const isNoneSelected = computed(() => selectedFacets.value.length === 1) 131 + const isNoneSelected = computed(() => selectedFacetIds.value.length === 1) 132 + 133 + // Get translated category name 134 + function getCategoryLabel(category: FacetInfo['category']): string { 135 + return t(`compare.facets.categories.${category}`) 136 + } 137 + 138 + // All facets with their info and i18n labels, grouped by category 139 + const facetsByCategory = computed(() => { 140 + const result: Record<string, FacetInfoWithLabels[]> = {} 141 + for (const category of CATEGORY_ORDER) { 142 + result[category] = FACETS_BY_CATEGORY[category].map(buildFacetInfo) 143 + } 144 + return result 145 + }) 102 146 103 147 return { 104 148 selectedFacets, ··· 111 155 isAllSelected, 112 156 isNoneSelected, 113 157 allFacets: ALL_FACETS, 158 + // Facet info with i18n 159 + getCategoryLabel, 160 + facetsByCategory, 161 + categoryOrder: CATEGORY_ORDER, 114 162 } 115 163 } 116 164
+23 -6
app/composables/usePackageComparison.ts
··· 244 244 function computeFacetValue( 245 245 facet: ComparisonFacet, 246 246 data: PackageComparisonData, 247 - t: (key: string) => string, 247 + t: (key: string, params?: Record<string, unknown>) => string, 248 248 ): FacetValue | null { 249 249 switch (facet) { 250 250 case 'downloads': ··· 294 294 return { 295 295 raw: types.kind, 296 296 display: 297 - types.kind === 'included' ? 'Included' : types.kind === '@types' ? '@types' : 'None', 297 + types.kind === 'included' 298 + ? t('compare.facets.values.types_included') 299 + : types.kind === '@types' 300 + ? '@types' 301 + : t('compare.facets.values.types_none'), 298 302 status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad', 299 303 } 300 304 301 305 case 'engines': 302 306 const engines = data.metadata?.engines 303 - if (!engines?.node) return { raw: null, display: 'Any', status: 'neutral' } 307 + if (!engines?.node) { 308 + return { raw: null, display: t('compare.facets.values.any'), status: 'neutral' } 309 + } 304 310 return { 305 311 raw: engines.node, 306 312 display: `Node ${engines.node}`, ··· 313 319 const sev = data.vulnerabilities.severity 314 320 return { 315 321 raw: count, 316 - display: count === 0 ? 'None' : `${count} (${sev.critical}C/${sev.high}H)`, 322 + display: 323 + count === 0 324 + ? t('compare.facets.values.none') 325 + : t('compare.facets.values.vulnerabilities_summary', { 326 + count, 327 + critical: sev.critical, 328 + high: sev.high, 329 + }), 317 330 status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning', 318 331 } 319 332 ··· 329 342 330 343 case 'license': 331 344 const license = data.metadata?.license 332 - if (!license) return { raw: null, display: 'Unknown', status: 'warning' } 345 + if (!license) { 346 + return { raw: null, display: t('compare.facets.values.unknown'), status: 'warning' } 347 + } 333 348 return { 334 349 raw: license, 335 350 display: license, ··· 349 364 const isDeprecated = !!data.metadata?.deprecated 350 365 return { 351 366 raw: isDeprecated, 352 - display: isDeprecated ? 'Deprecated' : 'No', 367 + display: isDeprecated 368 + ? t('compare.facets.values.deprecated') 369 + : t('compare.facets.values.not_deprecated'), 353 370 status: isDeprecated ? 'bad' : 'good', 354 371 } 355 372
+33 -12
app/composables/useStructuredFilters.ts
··· 15 15 DEFAULT_FILTERS, 16 16 DOWNLOAD_RANGES, 17 17 parseSortOption, 18 - SECURITY_FILTER_OPTIONS, 19 18 UPDATED_WITHIN_OPTIONS, 20 19 } from '#shared/types/preferences' 21 20 ··· 114 113 */ 115 114 export function useStructuredFilters(options: UseStructuredFiltersOptions) { 116 115 const { packages, initialFilters, initialSort } = options 116 + const { t } = useI18n() 117 117 118 118 // Filter state 119 119 const filters = ref<StructuredFilters>({ ··· 292 292 return [...filteredPackages.value].sort((a, b) => comparePackages(a, b, sortOption.value)) 293 293 }) 294 294 295 + // i18n key mappings for filter chip values 296 + const downloadRangeKeys: Record<DownloadRange, string> = { 297 + 'any': 'filters.download_range.any', 298 + 'lt100': 'filters.download_range.lt100', 299 + '100-1k': 'filters.download_range.100_1k', 300 + '1k-10k': 'filters.download_range.1k_10k', 301 + '10k-100k': 'filters.download_range.10k_100k', 302 + 'gt100k': 'filters.download_range.gt100k', 303 + } 304 + 305 + const securityKeys: Record<SecurityFilter, string> = { 306 + all: 'filters.security_options.all', 307 + secure: 'filters.security_options.secure', 308 + warnings: 'filters.security_options.insecure', 309 + } 310 + 311 + const updatedWithinKeys: Record<UpdatedWithin, string> = { 312 + any: 'filters.updated.any', 313 + week: 'filters.updated.week', 314 + month: 'filters.updated.month', 315 + quarter: 'filters.updated.quarter', 316 + year: 'filters.updated.year', 317 + } 318 + 295 319 // Active filter chips for display 296 320 const activeFilters = computed<FilterChip[]>(() => { 297 321 const chips: FilterChip[] = [] ··· 300 324 chips.push({ 301 325 id: 'text', 302 326 type: 'text', 303 - label: 'Search', 327 + label: t('filters.chips.search'), 304 328 value: filters.value.text, 305 329 }) 306 330 } 307 331 308 332 if (filters.value.downloadRange !== 'any') { 309 - const config = DOWNLOAD_RANGES.find(r => r.value === filters.value.downloadRange) 310 333 chips.push({ 311 334 id: 'downloadRange', 312 335 type: 'downloadRange', 313 - label: 'Downloads', 314 - value: config?.label ?? filters.value.downloadRange, 336 + label: t('filters.chips.downloads'), 337 + value: t(downloadRangeKeys[filters.value.downloadRange]), 315 338 }) 316 339 } 317 340 ··· 319 342 chips.push({ 320 343 id: `keyword-${keyword}`, 321 344 type: 'keywords', 322 - label: 'Keyword', 345 + label: t('filters.chips.keyword'), 323 346 value: keyword, 324 347 }) 325 348 } 326 349 327 350 if (filters.value.security !== 'all') { 328 - const config = SECURITY_FILTER_OPTIONS.find(o => o.value === filters.value.security) 329 351 chips.push({ 330 352 id: 'security', 331 353 type: 'security', 332 - label: 'Security', 333 - value: config?.label ?? filters.value.security, 354 + label: t('filters.chips.security'), 355 + value: t(securityKeys[filters.value.security]), 334 356 }) 335 357 } 336 358 337 359 if (filters.value.updatedWithin !== 'any') { 338 - const config = UPDATED_WITHIN_OPTIONS.find(o => o.value === filters.value.updatedWithin) 339 360 chips.push({ 340 361 id: 'updatedWithin', 341 362 type: 'updatedWithin', 342 - label: 'Updated', 343 - value: config?.label ?? filters.value.updatedWithin, 363 + label: t('filters.chips.updated'), 364 + value: t(updatedWithinKeys[filters.value.updatedWithin]), 344 365 }) 345 366 } 346 367
+13 -14
app/pages/compare.vue
··· 1 1 <script setup lang="ts"> 2 - import { FACET_INFO } from '#shared/types/comparison' 3 2 import { useRouteQuery } from '@vueuse/router' 4 3 5 4 definePageMeta({ ··· 24 23 }, 25 24 }) 26 25 27 - // Facet selection 26 + // Facet selection and info 28 27 const { selectedFacets, selectAll, deselectAll, isAllSelected, isNoneSelected } = 29 28 useFacetSelection() 30 29 ··· 128 127 <CompareComparisonGrid :columns="packages.length" :headers="gridHeaders"> 129 128 <CompareFacetRow 130 129 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)" 130 + :key="facet.id" 131 + :label="facet.label" 132 + :description="facet.description" 133 + :values="getFacetValues(facet.id)" 134 + :facet-loading="isFacetLoading(facet.id)" 136 135 :column-loading="columnLoading" 137 - :bar="facet !== 'lastUpdated'" 136 + :bar="facet.id !== 'lastUpdated'" 138 137 :headers="gridHeaders" 139 138 /> 140 139 </CompareComparisonGrid> ··· 144 143 <div class="md:hidden space-y-3"> 145 144 <CompareFacetCard 146 145 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)" 146 + :key="facet.id" 147 + :label="facet.label" 148 + :description="facet.description" 149 + :values="getFacetValues(facet.id)" 150 + :facet-loading="isFacetLoading(facet.id)" 152 151 :column-loading="columnLoading" 153 - :bar="facet !== 'lastUpdated'" 152 + :bar="facet.id !== 'lastUpdated'" 154 153 :headers="gridHeaders" 155 154 /> 156 155 </div>
+67
i18n/locales/en.json
··· 633 633 "more_keywords": "+{count} more", 634 634 "clear_all": "Clear all", 635 635 "remove_filter": "Remove {label} filter", 636 + "chips": { 637 + "search": "Search", 638 + "downloads": "Downloads", 639 + "keyword": "Keyword", 640 + "security": "Security", 641 + "updated": "Updated" 642 + }, 636 643 "download_range": { 637 644 "any": "Any", 638 645 "lt100": "< 100", ··· 860 867 "health": "Health", 861 868 "compatibility": "Compatibility", 862 869 "security": "Security & Compliance" 870 + }, 871 + "items": { 872 + "packageSize": { 873 + "label": "Package Size", 874 + "description": "Size of the package itself (unpacked)" 875 + }, 876 + "installSize": { 877 + "label": "Install Size", 878 + "description": "Total install size including all dependencies" 879 + }, 880 + "dependencies": { 881 + "label": "# Direct Deps", 882 + "description": "Number of direct dependencies" 883 + }, 884 + "totalDependencies": { 885 + "label": "# Total Deps", 886 + "description": "Total number of dependencies including transitive" 887 + }, 888 + "downloads": { 889 + "label": "Downloads/wk", 890 + "description": "Weekly download count" 891 + }, 892 + "lastUpdated": { 893 + "label": "Published", 894 + "description": "When this version was published" 895 + }, 896 + "deprecated": { 897 + "label": "Deprecated?", 898 + "description": "Whether the package is deprecated" 899 + }, 900 + "engines": { 901 + "label": "Engines", 902 + "description": "Node.js version requirements" 903 + }, 904 + "types": { 905 + "label": "Types", 906 + "description": "TypeScript type definitions" 907 + }, 908 + "moduleFormat": { 909 + "label": "Module Format", 910 + "description": "ESM/CJS support" 911 + }, 912 + "license": { 913 + "label": "License", 914 + "description": "Package license" 915 + }, 916 + "vulnerabilities": { 917 + "label": "Vulnerabilities", 918 + "description": "Known security vulnerabilities" 919 + } 920 + }, 921 + "values": { 922 + "any": "Any", 923 + "none": "None", 924 + "unknown": "Unknown", 925 + "deprecated": "Deprecated", 926 + "not_deprecated": "No", 927 + "types_included": "Included", 928 + "types_none": "None", 929 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 863 930 } 864 931 } 865 932 }
+61 -1
i18n/locales/pl-PL.json
··· 290 290 "cjs": "Obsługuje CommonJS", 291 291 "no_esm": "Brak obsługi ES Modules", 292 292 "types_label": "Typy", 293 - "types_included": "Typy w pakiecie", 293 + "types_included": "Typy wbudowane", 294 294 "types_available": "Typy dostępne przez {package}", 295 295 "no_types": "Brak typów TypeScript" 296 296 }, ··· 850 850 "health": "Zdrowie", 851 851 "compatibility": "Kompatybilność", 852 852 "security": "Bezpieczeństwo i zgodność" 853 + }, 854 + "items": { 855 + "packageSize": { 856 + "label": "Rozmiar pakietu", 857 + "description": "Rozmiar samego pakietu (rozpakowany)" 858 + }, 859 + "installSize": { 860 + "label": "Rozmiar instalacji", 861 + "description": "Łączny rozmiar instalacji wraz ze wszystkimi zależnościami" 862 + }, 863 + "dependencies": { 864 + "label": "Bezpośrednie zależności", 865 + "description": "Liczba bezpośrednich zależności" 866 + }, 867 + "totalDependencies": { 868 + "label": "# Wszystkich zależności", 869 + "description": "Łączna liczba zależności, w tym przechodnich" 870 + }, 871 + "downloads": { 872 + "label": "Pobrania/tydz.", 873 + "description": "Tygodniowa liczba pobrań" 874 + }, 875 + "lastUpdated": { 876 + "label": "Opublikowano", 877 + "description": "Kiedy ta wersja została opublikowana" 878 + }, 879 + "deprecated": { 880 + "label": "Wycofany?", 881 + "description": "Czy pakiet jest wycofany" 882 + }, 883 + "engines": { 884 + "label": "Silniki", 885 + "description": "Wymagania wersji Node.js" 886 + }, 887 + "types": { 888 + "label": "Typy", 889 + "description": "Definicje typów TypeScript" 890 + }, 891 + "moduleFormat": { 892 + "label": "Format modułu", 893 + "description": "Obsługa ESM/CJS" 894 + }, 895 + "license": { 896 + "label": "Licencja", 897 + "description": "Licencja pakietu" 898 + }, 899 + "vulnerabilities": { 900 + "label": "Podatności", 901 + "description": "Znane luki bezpieczeństwa" 902 + } 903 + }, 904 + "values": { 905 + "any": "Dowolne", 906 + "none": "Brak", 907 + "unknown": "Nieznana", 908 + "deprecated": "Wycofany", 909 + "not_deprecated": "Nie", 910 + "types_included": "Wbudowane", 911 + "types_none": "Brak", 912 + "vulnerabilities_summary": "{count} ({critical}K/{high}W)" 853 913 } 854 914 } 855 915 }
+67
lunaria/files/en-US.json
··· 633 633 "more_keywords": "+{count} more", 634 634 "clear_all": "Clear all", 635 635 "remove_filter": "Remove {label} filter", 636 + "chips": { 637 + "search": "Search", 638 + "downloads": "Downloads", 639 + "keyword": "Keyword", 640 + "security": "Security", 641 + "updated": "Updated" 642 + }, 636 643 "download_range": { 637 644 "any": "Any", 638 645 "lt100": "< 100", ··· 860 867 "health": "Health", 861 868 "compatibility": "Compatibility", 862 869 "security": "Security & Compliance" 870 + }, 871 + "items": { 872 + "packageSize": { 873 + "label": "Package Size", 874 + "description": "Size of the package itself (unpacked)" 875 + }, 876 + "installSize": { 877 + "label": "Install Size", 878 + "description": "Total install size including all dependencies" 879 + }, 880 + "dependencies": { 881 + "label": "# Direct Deps", 882 + "description": "Number of direct dependencies" 883 + }, 884 + "totalDependencies": { 885 + "label": "# Total Deps", 886 + "description": "Total number of dependencies including transitive" 887 + }, 888 + "downloads": { 889 + "label": "Downloads/wk", 890 + "description": "Weekly download count" 891 + }, 892 + "lastUpdated": { 893 + "label": "Published", 894 + "description": "When this version was published" 895 + }, 896 + "deprecated": { 897 + "label": "Deprecated?", 898 + "description": "Whether the package is deprecated" 899 + }, 900 + "engines": { 901 + "label": "Engines", 902 + "description": "Node.js version requirements" 903 + }, 904 + "types": { 905 + "label": "Types", 906 + "description": "TypeScript type definitions" 907 + }, 908 + "moduleFormat": { 909 + "label": "Module Format", 910 + "description": "ESM/CJS support" 911 + }, 912 + "license": { 913 + "label": "License", 914 + "description": "Package license" 915 + }, 916 + "vulnerabilities": { 917 + "label": "Vulnerabilities", 918 + "description": "Known security vulnerabilities" 919 + } 920 + }, 921 + "values": { 922 + "any": "Any", 923 + "none": "None", 924 + "unknown": "Unknown", 925 + "deprecated": "Deprecated", 926 + "not_deprecated": "No", 927 + "types_included": "Included", 928 + "types_none": "None", 929 + "vulnerabilities_summary": "{count} ({critical}C/{high}H)" 863 930 } 864 931 } 865 932 }
+61 -1
lunaria/files/pl-PL.json
··· 290 290 "cjs": "Obsługuje CommonJS", 291 291 "no_esm": "Brak obsługi ES Modules", 292 292 "types_label": "Typy", 293 - "types_included": "Typy w pakiecie", 293 + "types_included": "Typy wbudowane", 294 294 "types_available": "Typy dostępne przez {package}", 295 295 "no_types": "Brak typów TypeScript" 296 296 }, ··· 850 850 "health": "Zdrowie", 851 851 "compatibility": "Kompatybilność", 852 852 "security": "Bezpieczeństwo i zgodność" 853 + }, 854 + "items": { 855 + "packageSize": { 856 + "label": "Rozmiar pakietu", 857 + "description": "Rozmiar samego pakietu (rozpakowany)" 858 + }, 859 + "installSize": { 860 + "label": "Rozmiar instalacji", 861 + "description": "Łączny rozmiar instalacji wraz ze wszystkimi zależnościami" 862 + }, 863 + "dependencies": { 864 + "label": "Bezpośrednie zależności", 865 + "description": "Liczba bezpośrednich zależności" 866 + }, 867 + "totalDependencies": { 868 + "label": "# Wszystkich zależności", 869 + "description": "Łączna liczba zależności, w tym przechodnich" 870 + }, 871 + "downloads": { 872 + "label": "Pobrania/tydz.", 873 + "description": "Tygodniowa liczba pobrań" 874 + }, 875 + "lastUpdated": { 876 + "label": "Opublikowano", 877 + "description": "Kiedy ta wersja została opublikowana" 878 + }, 879 + "deprecated": { 880 + "label": "Wycofany?", 881 + "description": "Czy pakiet jest wycofany" 882 + }, 883 + "engines": { 884 + "label": "Silniki", 885 + "description": "Wymagania wersji Node.js" 886 + }, 887 + "types": { 888 + "label": "Typy", 889 + "description": "Definicje typów TypeScript" 890 + }, 891 + "moduleFormat": { 892 + "label": "Format modułu", 893 + "description": "Obsługa ESM/CJS" 894 + }, 895 + "license": { 896 + "label": "Licencja", 897 + "description": "Licencja pakietu" 898 + }, 899 + "vulnerabilities": { 900 + "label": "Podatności", 901 + "description": "Znane luki bezpieczeństwa" 902 + } 903 + }, 904 + "values": { 905 + "any": "Dowolne", 906 + "none": "Brak", 907 + "unknown": "Nieznana", 908 + "deprecated": "Wycofany", 909 + "not_deprecated": "Nie", 910 + "types_included": "Wbudowane", 911 + "types_none": "Brak", 912 + "vulnerabilities_summary": "{count} ({critical}K/{high}W)" 853 913 } 854 914 } 855 915 }
-26
shared/types/comparison.ts
··· 20 20 /** Facet metadata for UI display */ 21 21 export interface FacetInfo { 22 22 id: ComparisonFacet 23 - label: string 24 - description: string 25 23 category: 'performance' | 'health' | 'compatibility' | 'security' 26 24 comingSoon?: boolean 27 25 } ··· 33 31 export const FACET_INFO: Record<ComparisonFacet, Omit<FacetInfo, 'id'>> = { 34 32 // Performance 35 33 packageSize: { 36 - label: 'Package Size', 37 - description: 'Size of the package itself (unpacked)', 38 34 category: 'performance', 39 35 }, 40 36 installSize: { 41 - label: 'Install Size', 42 - description: 'Total install size including all dependencies', 43 37 category: 'performance', 44 38 }, 45 39 dependencies: { 46 - label: '# Direct Deps', 47 - description: 'Number of direct dependencies', 48 40 category: 'performance', 49 41 }, 50 42 totalDependencies: { 51 - label: '# Total Deps', 52 - description: 'Total number of dependencies including transitive', 53 43 category: 'performance', 54 44 comingSoon: true, 55 45 }, 56 46 // Health 57 47 downloads: { 58 - label: 'Downloads/wk', 59 - description: 'Weekly download count', 60 48 category: 'health', 61 49 }, 62 50 lastUpdated: { 63 - label: 'Published', 64 - description: 'When this version was published', 65 51 category: 'health', 66 52 }, 67 53 deprecated: { 68 - label: 'Deprecated?', 69 - description: 'Whether the package is deprecated', 70 54 category: 'health', 71 55 }, 72 56 // Compatibility 73 57 engines: { 74 - label: 'Engines', 75 - description: 'Node.js version requirements', 76 58 category: 'compatibility', 77 59 }, 78 60 types: { 79 - label: 'Types', 80 - description: 'TypeScript type definitions', 81 61 category: 'compatibility', 82 62 }, 83 63 moduleFormat: { 84 - label: 'Module Format', 85 - description: 'ESM/CJS support', 86 64 category: 'compatibility', 87 65 }, 88 66 // Security 89 67 license: { 90 - label: 'License', 91 - description: 'Package license', 92 68 category: 'security', 93 69 }, 94 70 vulnerabilities: { 95 - label: 'Vulnerabilities', 96 - description: 'Known security vulnerabilities', 97 71 category: 'security', 98 72 }, 99 73 }
+36 -96
shared/types/preferences.ts
··· 23 23 24 24 export interface ColumnConfig { 25 25 id: ColumnId 26 - label: string 27 26 visible: boolean 28 27 sortable: boolean 29 28 width?: string ··· 33 32 34 33 // Default column configuration 35 34 export const DEFAULT_COLUMNS: ColumnConfig[] = [ 36 - { id: 'name', label: 'Name', visible: true, sortable: true, width: 'minmax(200px, 1fr)' }, 37 - { id: 'version', label: 'Version', visible: true, sortable: false, width: '100px' }, 35 + { id: 'name', visible: true, sortable: true, width: 'minmax(200px, 1fr)' }, 36 + { id: 'version', visible: true, sortable: false, width: '100px' }, 38 37 { 39 38 id: 'description', 40 - label: 'Description', 41 39 visible: true, 42 40 sortable: false, 43 41 width: 'minmax(200px, 2fr)', 44 42 }, 45 - { id: 'downloads', label: 'Downloads/wk', visible: true, sortable: true, width: '120px' }, 46 - { id: 'updated', label: 'Updated', visible: true, sortable: true, width: '120px' }, 47 - { id: 'maintainers', label: 'Maintainers', visible: false, sortable: false, width: '150px' }, 48 - { id: 'keywords', label: 'Keywords', visible: false, sortable: false, width: '200px' }, 43 + { id: 'downloads', visible: true, sortable: true, width: '120px' }, 44 + { id: 'updated', visible: true, sortable: true, width: '120px' }, 45 + { id: 'maintainers', visible: false, sortable: false, width: '150px' }, 46 + { id: 'keywords', visible: false, sortable: false, width: '200px' }, 49 47 { 50 48 id: 'qualityScore', 51 - label: 'Quality score', 52 49 visible: false, 53 50 sortable: true, 54 51 width: '100px', ··· 56 53 }, 57 54 { 58 55 id: 'popularityScore', 59 - label: 'Popularity score', 60 56 visible: false, 61 57 sortable: true, 62 58 width: '100px', ··· 64 60 }, 65 61 { 66 62 id: 'maintenanceScore', 67 - label: 'Maintenance score', 68 63 visible: false, 69 64 sortable: true, 70 65 width: '100px', ··· 72 67 }, 73 68 { 74 69 id: 'combinedScore', 75 - label: 'Combined score', 76 70 visible: false, 77 71 sortable: true, 78 72 width: '100px', ··· 80 74 }, 81 75 { 82 76 id: 'security', 83 - label: 'Security', 84 77 visible: false, 85 78 sortable: false, 86 79 width: '80px', ··· 131 124 132 125 export interface SortKeyConfig { 133 126 key: SortKey 134 - label: string 135 127 /** Default direction for this sort key */ 136 128 defaultDirection: SortDirection 137 129 /** Whether the sort option is disabled (not yet available) */ ··· 141 133 } 142 134 143 135 export const SORT_KEYS: SortKeyConfig[] = [ 144 - { key: 'relevance', label: 'Relevance', defaultDirection: 'desc', searchOnly: true }, 145 - { key: 'downloads-week', label: 'Downloads/wk', defaultDirection: 'desc' }, 146 - { 147 - key: 'downloads-day', 148 - label: 'Downloads/day', 149 - defaultDirection: 'desc', 150 - disabled: true, 151 - }, 152 - { 153 - key: 'downloads-month', 154 - label: 'Downloads/mo', 155 - defaultDirection: 'desc', 156 - disabled: true, 157 - }, 158 - { 159 - key: 'downloads-year', 160 - label: 'Downloads/yr', 161 - defaultDirection: 'desc', 162 - disabled: true, 163 - }, 164 - { key: 'updated', label: 'Updated', defaultDirection: 'desc' }, 165 - { key: 'name', label: 'Name', defaultDirection: 'asc' }, 166 - { 167 - key: 'quality', 168 - label: 'Quality', 169 - defaultDirection: 'desc', 170 - disabled: true, 171 - }, 172 - { 173 - key: 'popularity', 174 - label: 'Popularity', 175 - defaultDirection: 'desc', 176 - disabled: true, 177 - }, 178 - { 179 - key: 'maintenance', 180 - label: 'Maintenance', 181 - defaultDirection: 'desc', 182 - disabled: true, 183 - }, 184 - { 185 - key: 'score', 186 - label: 'Score', 187 - defaultDirection: 'desc', 188 - disabled: true, 189 - }, 136 + { key: 'relevance', defaultDirection: 'desc', searchOnly: true }, 137 + { key: 'downloads-week', defaultDirection: 'desc' }, 138 + { key: 'downloads-day', defaultDirection: 'desc', disabled: true }, 139 + { key: 'downloads-month', defaultDirection: 'desc', disabled: true }, 140 + { key: 'downloads-year', defaultDirection: 'desc', disabled: true }, 141 + { key: 'updated', defaultDirection: 'desc' }, 142 + { key: 'name', defaultDirection: 'asc' }, 143 + { key: 'quality', defaultDirection: 'desc', disabled: true }, 144 + { key: 'popularity', defaultDirection: 'desc', disabled: true }, 145 + { key: 'maintenance', defaultDirection: 'desc', disabled: true }, 146 + { key: 'score', defaultDirection: 'desc', disabled: true }, 190 147 ] 191 148 192 149 /** All valid sort keys for validation */ ··· 205 162 ]) 206 163 207 164 /** Parse a SortOption into key and direction */ 208 - export function parseSortOption(option: SortOption): { key: SortKey; direction: SortDirection } { 165 + export function parseSortOption(option: SortOption): { 166 + key: SortKey 167 + direction: SortDirection 168 + } { 209 169 const match = option.match(/^(.+)-(asc|desc)$/) 210 170 if (match) { 211 171 const key = match[1] ··· 234 194 235 195 export interface DownloadRangeConfig { 236 196 value: DownloadRange 237 - label: string 238 197 min?: number 239 198 max?: number 240 199 } 241 200 242 201 export const DOWNLOAD_RANGES: DownloadRangeConfig[] = [ 243 - { value: 'any', label: 'Any' }, 244 - { value: 'lt100', label: '< 100', max: 100 }, 245 - { value: '100-1k', label: '100 - 1K', min: 100, max: 1000 }, 246 - { value: '1k-10k', label: '1K - 10K', min: 1000, max: 10000 }, 247 - { value: '10k-100k', label: '10K - 100K', min: 10000, max: 100000 }, 248 - { value: 'gt100k', label: '> 100K', min: 100000 }, 202 + { value: 'any' }, 203 + { value: 'lt100', max: 100 }, 204 + { value: '100-1k', min: 100, max: 1000 }, 205 + { value: '1k-10k', min: 1000, max: 10000 }, 206 + { value: '10k-100k', min: 10000, max: 100000 }, 207 + { value: 'gt100k', min: 100000 }, 249 208 ] 250 209 251 210 // Updated within presets ··· 253 212 254 213 export interface UpdatedWithinConfig { 255 214 value: UpdatedWithin 256 - label: string 257 215 days?: number 258 216 } 259 217 260 218 export const UPDATED_WITHIN_OPTIONS: UpdatedWithinConfig[] = [ 261 - { value: 'any', label: 'Any time' }, 262 - { value: 'week', label: 'Past week', days: 7 }, 263 - { value: 'month', label: 'Past month', days: 30 }, 264 - { value: 'quarter', label: 'Past 3 months', days: 90 }, 265 - { value: 'year', label: 'Past year', days: 365 }, 219 + { value: 'any' }, 220 + { value: 'week', days: 7 }, 221 + { value: 'month', days: 30 }, 222 + { value: 'quarter', days: 90 }, 223 + { value: 'year', days: 365 }, 266 224 ] 267 225 268 226 // Security filter options 269 227 export type SecurityFilter = 'all' | 'secure' | 'warnings' 270 228 271 - export interface SecurityFilterConfig { 272 - value: SecurityFilter 273 - label: string 274 - } 275 - 276 - export const SECURITY_FILTER_OPTIONS: SecurityFilterConfig[] = [ 277 - { value: 'all', label: 'All packages' }, 278 - { value: 'secure', label: 'Secure only' }, 279 - { value: 'warnings', label: 'Insecure only' }, 280 - ] 229 + /** Security filter values - labels are in i18n under filters.security_options */ 230 + export const SECURITY_FILTER_VALUES: SecurityFilter[] = ['all', 'secure', 'warnings'] 281 231 282 232 // Search scope options 283 233 export type SearchScope = 'name' | 'description' | 'keywords' | 'all' 284 234 285 - export interface SearchScopeConfig { 286 - value: SearchScope 287 - label: string 288 - description: string 289 - } 290 - 291 - export const SEARCH_SCOPE_OPTIONS: SearchScopeConfig[] = [ 292 - { value: 'name', label: 'Name', description: 'Search package names only' }, 293 - { value: 'description', label: 'Description', description: 'Search descriptions only' }, 294 - { value: 'keywords', label: 'Keywords', description: 'Search keywords only' }, 295 - { value: 'all', label: 'All', description: 'Search name, description, and keywords' }, 296 - ] 235 + /** Search scope values - labels are in i18n under filters.scope_* */ 236 + export const SEARCH_SCOPE_VALUES: SearchScope[] = ['name', 'description', 'keywords', 'all'] 297 237 298 238 // Structured filters state 299 239 export interface StructuredFilters {
+46 -35
test/nuxt/a11y.spec.ts
··· 926 926 927 927 describe('ColumnPicker', () => { 928 928 const mockColumns: ColumnConfig[] = [ 929 - { id: 'name', label: 'Name', visible: true, sortable: true }, 930 - { id: 'version', label: 'Version', visible: true, sortable: false }, 931 - { id: 'downloads', label: 'Downloads', visible: false, sortable: true }, 929 + { id: 'name', visible: true, sortable: true }, 930 + { id: 'version', visible: true, sortable: false }, 931 + { id: 'downloads', visible: false, sortable: true }, 932 932 ] 933 933 934 934 it('should have no accessibility violations', async () => { ··· 1006 1006 } 1007 1007 1008 1008 const mockColumns: ColumnConfig[] = [ 1009 - { id: 'name', label: 'Name', visible: true, sortable: true }, 1010 - { id: 'version', label: 'Version', visible: true, sortable: false }, 1009 + { id: 'name', visible: true, sortable: true }, 1010 + { id: 'version', visible: true, sortable: false }, 1011 1011 ] 1012 1012 1013 1013 it('should have no accessibility violations', async () => { ··· 1088 1088 ] 1089 1089 1090 1090 const mockColumns: ColumnConfig[] = [ 1091 - { id: 'name', label: 'Name', visible: true, sortable: true }, 1092 - { id: 'version', label: 'Version', visible: true, sortable: false }, 1093 - { 1094 - id: 'description', 1095 - label: 'Description', 1096 - visible: true, 1097 - sortable: false, 1098 - }, 1099 - { id: 'downloads', label: 'Downloads', visible: true, sortable: true }, 1091 + { id: 'name', visible: true, sortable: true }, 1092 + { id: 'version', visible: true, sortable: false }, 1093 + { id: 'description', visible: true, sortable: false }, 1094 + { id: 'downloads', visible: true, sortable: true }, 1100 1095 ] 1101 1096 1102 1097 it('should have no accessibility violations', async () => { ··· 1156 1151 } 1157 1152 1158 1153 const mockColumns: ColumnConfig[] = [ 1159 - { id: 'name', label: 'Name', visible: true, sortable: true }, 1160 - { id: 'version', label: 'Version', visible: true, sortable: false }, 1161 - { 1162 - id: 'description', 1163 - label: 'Description', 1164 - visible: true, 1165 - sortable: false, 1166 - }, 1154 + { id: 'name', visible: true, sortable: true }, 1155 + { id: 'version', visible: true, sortable: false }, 1156 + { id: 'description', visible: true, sortable: false }, 1167 1157 ] 1168 1158 1169 1159 it('should have no accessibility violations', async () => { ··· 1504 1494 1505 1495 it('should have no accessibility violations with custom heading level', async () => { 1506 1496 const component = await mountSuspended(CollapsibleSection, { 1507 - props: { title: 'Section Title', id: 'test-section', headingLevel: 'h3' }, 1497 + props: { 1498 + title: 'Section Title', 1499 + id: 'test-section', 1500 + headingLevel: 'h3', 1501 + }, 1508 1502 slots: { default: '<p>Section content</p>' }, 1509 1503 }) 1510 1504 const results = await runAxe(component) ··· 1760 1754 1761 1755 it('should have no accessibility violations with description', async () => { 1762 1756 const component = await mountSuspended(ToggleServer, { 1763 - props: { label: 'Enable feature', description: 'This enables the feature' }, 1757 + props: { 1758 + label: 'Enable feature', 1759 + description: 'This enables the feature', 1760 + }, 1764 1761 }) 1765 1762 const results = await runAxe(component) 1766 1763 expect(results.violations).toEqual([]) ··· 1778 1775 1779 1776 it('should have no accessibility violations with description', async () => { 1780 1777 const component = await mountSuspended(SettingsToggle, { 1781 - props: { label: 'Enable feature', description: 'This enables the feature' }, 1778 + props: { 1779 + label: 'Enable feature', 1780 + description: 'This enables the feature', 1781 + }, 1782 1782 }) 1783 1783 const results = await runAxe(component) 1784 1784 expect(results.violations).toEqual([]) ··· 1860 1860 }) 1861 1861 }) 1862 1862 1863 + function applyTheme(colorMode: string, bgTheme: string | null) { 1864 + document.documentElement.dataset.theme = colorMode 1865 + document.documentElement.classList.add(colorMode) 1866 + if (bgTheme) document.documentElement.dataset.bgTheme = bgTheme 1867 + } 1868 + 1863 1869 describe('background theme accessibility', () => { 1864 1870 const pairs = [ 1865 1871 ['light', 'neutral'], ··· 1874 1880 ['dark', 'black'], 1875 1881 ] as const 1876 1882 1877 - function applyTheme(colorMode: string, bgTheme: string | null) { 1878 - document.documentElement.dataset.theme = colorMode 1879 - document.documentElement.classList.add(colorMode) 1880 - if (bgTheme) document.documentElement.dataset.bgTheme = bgTheme 1881 - } 1882 - 1883 1883 afterEach(() => { 1884 1884 document.documentElement.removeAttribute('data-theme') 1885 1885 document.documentElement.removeAttribute('data-bg-theme') ··· 1896 1896 links: {}, 1897 1897 publisher: { username: 'evan' }, 1898 1898 }, 1899 - score: { final: 0.9, detail: { quality: 0.9, popularity: 0.9, maintenance: 0.9 } }, 1899 + score: { 1900 + final: 0.9, 1901 + detail: { quality: 0.9, popularity: 0.9, maintenance: 0.9 }, 1902 + }, 1900 1903 searchScore: 100000, 1901 1904 } 1902 1905 ··· 1911 1914 { 1912 1915 name: 'SettingsToggle', 1913 1916 mount: () => 1914 - mountSuspended(SettingsToggle, { props: { label: 'Feature', description: 'Desc' } }), 1917 + mountSuspended(SettingsToggle, { 1918 + props: { label: 'Feature', description: 'Desc' }, 1919 + }), 1915 1920 }, 1916 - { name: 'SettingsBgThemePicker', mount: () => mountSuspended(SettingsBgThemePicker) }, 1921 + { 1922 + name: 'SettingsBgThemePicker', 1923 + mount: () => mountSuspended(SettingsBgThemePicker), 1924 + }, 1917 1925 { 1918 1926 name: 'ProvenanceBadge', 1919 1927 mount: () => ··· 1931 1939 }, 1932 1940 { 1933 1941 name: 'DateTime', 1934 - mount: () => mountSuspended(DateTime, { props: { datetime: '2024-01-15T12:00:00.000Z' } }), 1942 + mount: () => 1943 + mountSuspended(DateTime, { 1944 + props: { datetime: '2024-01-15T12:00:00.000Z' }, 1945 + }), 1935 1946 }, 1936 1947 { 1937 1948 name: 'ViewModeToggle',
+56 -5
test/nuxt/components/compare/FacetSelector.spec.ts
··· 2 2 import { CATEGORY_ORDER, FACET_INFO, FACETS_BY_CATEGORY } from '#shared/types/comparison' 3 3 import FacetSelector from '~/components/compare/FacetSelector.vue' 4 4 import { beforeEach, describe, expect, it, vi } from 'vitest' 5 - import { ref } from 'vue' 5 + import { computed, ref } from 'vue' 6 6 import { mountSuspended } from '@nuxt/test-utils/runtime' 7 7 8 + // Create facet label/description lookup 9 + const facetLabels: Record<ComparisonFacet, { label: string; description: string }> = { 10 + downloads: { label: 'Downloads/wk', description: 'Weekly download count' }, 11 + packageSize: { label: 'Package Size', description: 'Size of the package itself (unpacked)' }, 12 + installSize: { 13 + label: 'Install Size', 14 + description: 'Total install size including all dependencies', 15 + }, 16 + moduleFormat: { label: 'Module Format', description: 'ESM/CJS support' }, 17 + types: { label: 'Types', description: 'TypeScript type definitions' }, 18 + engines: { label: 'Engines', description: 'Node.js version requirements' }, 19 + vulnerabilities: { label: 'Vulnerabilities', description: 'Known security vulnerabilities' }, 20 + lastUpdated: { label: 'Published', description: 'When this version was published' }, 21 + license: { label: 'License', description: 'Package license' }, 22 + dependencies: { label: '# Direct Deps', description: 'Number of direct dependencies' }, 23 + totalDependencies: { 24 + label: '# Total Deps', 25 + description: 'Total number of dependencies including transitive', 26 + }, 27 + deprecated: { label: 'Deprecated?', description: 'Whether the package is deprecated' }, 28 + } 29 + 30 + const categoryLabels: Record<string, string> = { 31 + performance: 'Performance', 32 + health: 'Health', 33 + compatibility: 'Compatibility', 34 + security: 'Security & Compliance', 35 + } 36 + 37 + // Helper to build facet info with labels 38 + function buildFacetInfo(facet: ComparisonFacet) { 39 + return { 40 + id: facet, 41 + ...FACET_INFO[facet], 42 + label: facetLabels[facet]?.label ?? facet, 43 + description: facetLabels[facet]?.description ?? '', 44 + } 45 + } 46 + 8 47 // Mock useFacetSelection 9 48 const mockSelectedFacets = ref<string[]>(['downloads', 'types']) 10 49 const mockIsFacetSelected = vi.fn((facet: string) => mockSelectedFacets.value.includes(facet)) ··· 18 57 19 58 vi.mock('~/composables/useFacetSelection', () => ({ 20 59 useFacetSelection: () => ({ 21 - selectedFacets: mockSelectedFacets, 60 + selectedFacets: computed(() => 61 + mockSelectedFacets.value.map(id => buildFacetInfo(id as ComparisonFacet)), 62 + ), 22 63 isFacetSelected: mockIsFacetSelected, 23 64 toggleFacet: mockToggleFacet, 24 65 selectCategory: mockSelectCategory, ··· 27 68 deselectAll: mockDeselectAll, 28 69 isAllSelected: mockIsAllSelected, 29 70 isNoneSelected: mockIsNoneSelected, 71 + // Facet info with i18n 72 + getCategoryLabel: (category: string) => categoryLabels[category] ?? category, 73 + facetsByCategory: computed(() => { 74 + const result: Record<string, ReturnType<typeof buildFacetInfo>[]> = {} 75 + for (const category of CATEGORY_ORDER) { 76 + result[category] = FACETS_BY_CATEGORY[category].map(facet => buildFacetInfo(facet)) 77 + } 78 + return result 79 + }), 80 + categoryOrder: CATEGORY_ORDER, 30 81 }), 31 82 })) 32 83 ··· 77 128 it('renders all facets from FACET_INFO', async () => { 78 129 const component = await mountSuspended(FacetSelector) 79 130 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) 131 + for (const facet of Object.keys(FACET_INFO) as ComparisonFacet[]) { 132 + const label = facetLabels[facet]?.label ?? facet 133 + expect(component.text()).toContain(label) 83 134 } 84 135 }) 85 136
+76 -44
test/nuxt/composables/structured-filters.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 2 3 import type { NpmSearchResult } from '#shared/types' 3 4 4 5 // Helper to create mock package results ··· 29 30 } 30 31 } 31 32 33 + /** 34 + * Helper to test useStructuredFilters by wrapping it in a component. 35 + * This is required because the composable uses useI18n which must be 36 + * called inside a Vue component's setup function. 37 + */ 38 + async function useStructuredFiltersInComponent(packages: Ref<NpmSearchResult[]>) { 39 + let capturedResult: ReturnType<typeof useStructuredFilters> 40 + 41 + const WrapperComponent = defineComponent({ 42 + setup() { 43 + capturedResult = useStructuredFilters({ packages }) 44 + return () => h('div') 45 + }, 46 + }) 47 + 48 + await mountSuspended(WrapperComponent) 49 + 50 + return capturedResult! 51 + } 52 + 32 53 describe('useStructuredFilters', () => { 33 54 describe('keyword filtering (AND logic)', () => { 34 - it('returns all packages when no keywords selected', () => { 55 + it('returns all packages when no keywords selected', async () => { 35 56 const packages = ref([ 36 57 createPackage({ name: 'pkg-a', keywords: ['react'] }), 37 58 createPackage({ name: 'pkg-b', keywords: ['vue'] }), 38 59 ]) 39 60 40 - const { sortedPackages } = useStructuredFilters({ packages }) 61 + const { sortedPackages } = await useStructuredFiltersInComponent(packages) 41 62 42 63 expect(sortedPackages.value).toHaveLength(2) 43 64 }) 44 65 45 - it('filters to packages with single keyword', () => { 66 + it('filters to packages with single keyword', async () => { 46 67 const packages = ref([ 47 68 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }), 48 69 createPackage({ name: 'pkg-b', keywords: ['vue'] }), 49 70 ]) 50 71 51 - const { sortedPackages, addKeyword } = useStructuredFilters({ packages }) 72 + const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages) 52 73 addKeyword('react') 53 74 54 75 expect(sortedPackages.value).toHaveLength(1) 55 76 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a') 56 77 }) 57 78 58 - it('uses AND logic for multiple keywords', () => { 79 + it('uses AND logic for multiple keywords', async () => { 59 80 const packages = ref([ 60 81 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }), 61 82 createPackage({ name: 'pkg-b', keywords: ['react', 'state'] }), 62 83 createPackage({ name: 'pkg-c', keywords: ['react', 'hooks', 'state'] }), 63 84 ]) 64 85 65 - const { sortedPackages, addKeyword } = useStructuredFilters({ packages }) 86 + const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages) 66 87 addKeyword('react') 67 88 addKeyword('hooks') 68 89 ··· 72 93 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-c') 73 94 }) 74 95 75 - it('returns empty when no package has all keywords', () => { 96 + it('returns empty when no package has all keywords', async () => { 76 97 const packages = ref([ 77 98 createPackage({ name: 'pkg-a', keywords: ['react'] }), 78 99 createPackage({ name: 'pkg-b', keywords: ['hooks'] }), 79 100 ]) 80 101 81 - const { sortedPackages, addKeyword } = useStructuredFilters({ packages }) 102 + const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages) 82 103 addKeyword('react') 83 104 addKeyword('hooks') 84 105 ··· 87 108 }) 88 109 89 110 describe('text filtering', () => { 90 - it('filters by name when scope is name', () => { 111 + it('filters by name when scope is name', async () => { 91 112 const packages = ref([ 92 113 createPackage({ name: 'react-query', description: 'Data fetching' }), 93 114 createPackage({ name: 'vue-query', description: 'Data fetching for Vue' }), 94 115 ]) 95 116 96 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 117 + const { sortedPackages, setTextFilter, setSearchScope } = 118 + await useStructuredFiltersInComponent(packages) 97 119 setSearchScope('name') 98 120 setTextFilter('react') 99 121 ··· 101 123 expect(sortedPackages.value[0]!.package.name).toBe('react-query') 102 124 }) 103 125 104 - it('filters by description when scope is description', () => { 126 + it('filters by description when scope is description', async () => { 105 127 const packages = ref([ 106 128 createPackage({ name: 'pkg-a', description: 'A React component library' }), 107 129 createPackage({ name: 'pkg-b', description: 'A Vue component library' }), 108 130 ]) 109 131 110 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 132 + const { sortedPackages, setTextFilter, setSearchScope } = 133 + await useStructuredFiltersInComponent(packages) 111 134 setSearchScope('description') 112 135 setTextFilter('React') 113 136 ··· 115 138 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a') 116 139 }) 117 140 118 - it('filters by keywords when scope is keywords', () => { 141 + it('filters by keywords when scope is keywords', async () => { 119 142 const packages = ref([ 120 143 createPackage({ name: 'pkg-a', keywords: ['typescript', 'react'] }), 121 144 createPackage({ name: 'pkg-b', keywords: ['javascript', 'vue'] }), 122 145 ]) 123 146 124 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 147 + const { sortedPackages, setTextFilter, setSearchScope } = 148 + await useStructuredFiltersInComponent(packages) 125 149 setSearchScope('keywords') 126 150 setTextFilter('type') 127 151 ··· 129 153 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a') 130 154 }) 131 155 132 - it('filters by all fields when scope is all', () => { 156 + it('filters by all fields when scope is all', async () => { 133 157 const packages = ref([ 134 158 createPackage({ name: 'pkg-a', description: 'foo', keywords: ['bar'] }), 135 159 createPackage({ name: 'pkg-b', description: 'baz', keywords: ['qux'] }), 136 160 createPackage({ name: 'search-term', description: 'other', keywords: [] }), 137 161 ]) 138 162 139 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 163 + const { sortedPackages, setTextFilter, setSearchScope } = 164 + await useStructuredFiltersInComponent(packages) 140 165 setSearchScope('all') 141 166 setTextFilter('search-term') 142 167 ··· 146 171 }) 147 172 148 173 describe('text filtering with operators', () => { 149 - it('parses name: operator in all scope', () => { 174 + it('parses name: operator in all scope', async () => { 150 175 const packages = ref([ 151 176 createPackage({ name: 'react-query', description: 'vue stuff' }), 152 177 createPackage({ name: 'vue-query', description: 'react stuff' }), 153 178 ]) 154 179 155 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 180 + const { sortedPackages, setTextFilter, setSearchScope } = 181 + await useStructuredFiltersInComponent(packages) 156 182 setSearchScope('all') 157 183 setTextFilter('name:react') 158 184 ··· 160 186 expect(sortedPackages.value[0]!.package.name).toBe('react-query') 161 187 }) 162 188 163 - it('parses desc: operator in all scope', () => { 189 + it('parses desc: operator in all scope', async () => { 164 190 const packages = ref([ 165 191 createPackage({ name: 'pkg-a', description: 'A fantastic library' }), 166 192 createPackage({ name: 'pkg-b', description: 'A mediocre library' }), 167 193 ]) 168 194 169 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 195 + const { sortedPackages, setTextFilter, setSearchScope } = 196 + await useStructuredFiltersInComponent(packages) 170 197 setSearchScope('all') 171 198 setTextFilter('desc:fantastic') 172 199 ··· 174 201 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a') 175 202 }) 176 203 177 - it('parses kw: operator in all scope', () => { 204 + it('parses kw: operator in all scope', async () => { 178 205 const packages = ref([ 179 206 createPackage({ name: 'pkg-a', keywords: ['typescript'] }), 180 207 createPackage({ name: 'pkg-b', keywords: ['javascript'] }), 181 208 ]) 182 209 183 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 210 + const { sortedPackages, setTextFilter, setSearchScope } = 211 + await useStructuredFiltersInComponent(packages) 184 212 setSearchScope('all') 185 213 setTextFilter('kw:typescript') 186 214 ··· 188 216 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a') 189 217 }) 190 218 191 - it('combines multiple operators with AND logic', () => { 219 + it('combines multiple operators with AND logic', async () => { 192 220 const packages = ref([ 193 221 createPackage({ name: 'react-lib', description: 'hooks library', keywords: ['react'] }), 194 222 createPackage({ name: 'react-other', description: 'other stuff', keywords: ['react'] }), 195 223 createPackage({ name: 'vue-lib', description: 'hooks library', keywords: ['vue'] }), 196 224 ]) 197 225 198 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 226 + const { sortedPackages, setTextFilter, setSearchScope } = 227 + await useStructuredFiltersInComponent(packages) 199 228 setSearchScope('all') 200 229 setTextFilter('name:react desc:hooks') 201 230 ··· 203 232 expect(sortedPackages.value[0]!.package.name).toBe('react-lib') 204 233 }) 205 234 206 - it('handles comma-separated keyword values with OR logic', () => { 235 + it('handles comma-separated keyword values with OR logic', async () => { 207 236 const packages = ref([ 208 237 createPackage({ name: 'pkg-a', keywords: ['react'] }), 209 238 createPackage({ name: 'pkg-b', keywords: ['vue'] }), 210 239 createPackage({ name: 'pkg-c', keywords: ['angular'] }), 211 240 ]) 212 241 213 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 242 + const { sortedPackages, setTextFilter, setSearchScope } = 243 + await useStructuredFiltersInComponent(packages) 214 244 setSearchScope('all') 215 245 setTextFilter('kw:react,vue') 216 246 ··· 219 249 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-b') 220 250 }) 221 251 222 - it('combines operators with remaining text', () => { 252 + it('combines operators with remaining text', async () => { 223 253 const packages = ref([ 224 254 createPackage({ 225 255 name: 'react-query', ··· 234 264 }), 235 265 ]) 236 266 237 - const { sortedPackages, setTextFilter, setSearchScope } = useStructuredFilters({ packages }) 267 + const { sortedPackages, setTextFilter, setSearchScope } = 268 + await useStructuredFiltersInComponent(packages) 238 269 setSearchScope('all') 239 270 setTextFilter('name:react fetching') 240 271 ··· 244 275 }) 245 276 246 277 describe('download range filtering', () => { 247 - it('filters packages below threshold', () => { 278 + it('filters packages below threshold', async () => { 248 279 const packages = ref([ 249 280 createPackage({ name: 'popular', downloads: 50000 }), 250 281 createPackage({ name: 'unpopular', downloads: 50 }), 251 282 ]) 252 283 253 - const { sortedPackages, setDownloadRange } = useStructuredFilters({ packages }) 284 + const { sortedPackages, setDownloadRange } = await useStructuredFiltersInComponent(packages) 254 285 setDownloadRange('gt100k') 255 286 256 287 expect(sortedPackages.value).toHaveLength(0) 257 288 }) 258 289 259 - it('filters packages within range', () => { 290 + it('filters packages within range', async () => { 260 291 const packages = ref([ 261 292 createPackage({ name: 'pkg-a', downloads: 500 }), 262 293 createPackage({ name: 'pkg-b', downloads: 5000 }), 263 294 createPackage({ name: 'pkg-c', downloads: 50000 }), 264 295 ]) 265 296 266 - const { sortedPackages, setDownloadRange } = useStructuredFilters({ packages }) 297 + const { sortedPackages, setDownloadRange } = await useStructuredFiltersInComponent(packages) 267 298 setDownloadRange('1k-10k') 268 299 269 300 expect(sortedPackages.value).toHaveLength(1) ··· 272 303 }) 273 304 274 305 describe('sorting', () => { 275 - it('sorts by downloads descending by default with downloads sort', () => { 306 + it('sorts by downloads descending by default with downloads sort', async () => { 276 307 const packages = ref([ 277 308 createPackage({ name: 'pkg-a', downloads: 100 }), 278 309 createPackage({ name: 'pkg-b', downloads: 1000 }), 279 310 createPackage({ name: 'pkg-c', downloads: 500 }), 280 311 ]) 281 312 282 - const { sortedPackages, setSort } = useStructuredFilters({ packages }) 313 + const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages) 283 314 setSort('downloads-week-desc') 284 315 285 316 expect(sortedPackages.value[0]!.package.name).toBe('pkg-b') ··· 287 318 expect(sortedPackages.value[2]!.package.name).toBe('pkg-a') 288 319 }) 289 320 290 - it('sorts by name ascending', () => { 321 + it('sorts by name ascending', async () => { 291 322 const packages = ref([ 292 323 createPackage({ name: 'zlib' }), 293 324 createPackage({ name: 'axios' }), 294 325 createPackage({ name: 'lodash' }), 295 326 ]) 296 327 297 - const { sortedPackages, setSort } = useStructuredFilters({ packages }) 328 + const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages) 298 329 setSort('name-asc') 299 330 300 331 expect(sortedPackages.value[0]!.package.name).toBe('axios') ··· 302 333 expect(sortedPackages.value[2]!.package.name).toBe('zlib') 303 334 }) 304 335 305 - it('sorts by updated date descending', () => { 336 + it('sorts by updated date descending', async () => { 306 337 const packages = ref([ 307 338 createPackage({ name: 'old', updated: '2023-01-01T00:00:00.000Z' }), 308 339 createPackage({ name: 'new', updated: '2024-06-01T00:00:00.000Z' }), 309 340 createPackage({ name: 'mid', updated: '2024-01-01T00:00:00.000Z' }), 310 341 ]) 311 342 312 - const { sortedPackages, setSort } = useStructuredFilters({ packages }) 343 + const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages) 313 344 setSort('updated-desc') 314 345 315 346 expect(sortedPackages.value[0]!.package.name).toBe('new') ··· 317 348 expect(sortedPackages.value[2]!.package.name).toBe('old') 318 349 }) 319 350 320 - it('relevance sort preserves original order', () => { 351 + it('relevance sort preserves original order', async () => { 321 352 const packages = ref([ 322 353 createPackage({ name: 'first', downloads: 100 }), 323 354 createPackage({ name: 'second', downloads: 1000 }), 324 355 createPackage({ name: 'third', downloads: 500 }), 325 356 ]) 326 357 327 - const { sortedPackages, setSort } = useStructuredFilters({ packages }) 358 + const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages) 328 359 setSort('relevance-desc') 329 360 330 361 // Relevance should preserve the original order from the server ··· 335 366 }) 336 367 337 368 describe('clearing filters', () => { 338 - it('clearAllFilters resets all filters', () => { 369 + it('clearAllFilters resets all filters', async () => { 339 370 const packages = ref([ 340 371 createPackage({ name: 'pkg-a', keywords: ['react'], downloads: 1000 }), 341 372 createPackage({ name: 'pkg-b', keywords: ['vue'], downloads: 500 }), 342 373 ]) 343 374 344 375 const { sortedPackages, setTextFilter, addKeyword, setDownloadRange, clearAllFilters } = 345 - useStructuredFilters({ packages }) 376 + await useStructuredFiltersInComponent(packages) 346 377 347 378 setTextFilter('pkg-a') 348 379 addKeyword('react') ··· 355 386 expect(sortedPackages.value).toHaveLength(2) 356 387 }) 357 388 358 - it('removeKeyword removes specific keyword', () => { 389 + it('removeKeyword removes specific keyword', async () => { 359 390 const packages = ref([ 360 391 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }), 361 392 createPackage({ name: 'pkg-b', keywords: ['react'] }), 362 393 ]) 363 394 364 - const { sortedPackages, addKeyword, removeKeyword } = useStructuredFilters({ packages }) 395 + const { sortedPackages, addKeyword, removeKeyword } = 396 + await useStructuredFiltersInComponent(packages) 365 397 366 398 addKeyword('react') 367 399 addKeyword('hooks')
+206 -96
test/nuxt/composables/use-facet-selection.spec.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 2 3 import { ref } from 'vue' 4 + import type { ComparisonFacet } from '#shared/types/comparison' 3 5 import { DEFAULT_FACETS, FACETS_BY_CATEGORY } from '#shared/types/comparison' 6 + import type { FacetInfoWithLabels } from '~/composables/useFacetSelection' 4 7 5 - // Mock useRouteQuery 8 + // Mock useRouteQuery - needs to be outside of the helper to persist across calls 6 9 const mockRouteQuery = ref('') 7 10 vi.mock('@vueuse/router', () => ({ 8 11 useRouteQuery: () => mockRouteQuery, 9 12 })) 10 13 14 + /** 15 + * Helper to test useFacetSelection by wrapping it in a component. 16 + * This is required because the composable uses useI18n which must be 17 + * called inside a Vue component's setup function. 18 + */ 19 + async function useFacetSelectionInComponent() { 20 + // Create refs to capture the composable's return values 21 + const capturedSelectedFacets = shallowRef<FacetInfoWithLabels[]>([]) 22 + const capturedIsAllSelected = ref(false) 23 + const capturedIsNoneSelected = ref(false) 24 + let capturedIsFacetSelected: (facet: ComparisonFacet) => boolean 25 + let capturedToggleFacet: (facet: ComparisonFacet) => void 26 + let capturedSelectCategory: (category: string) => void 27 + let capturedDeselectCategory: (category: string) => void 28 + let capturedSelectAll: () => void 29 + let capturedDeselectAll: () => void 30 + let capturedAllFacets: ComparisonFacet[] 31 + 32 + const WrapperComponent = defineComponent({ 33 + setup() { 34 + const { 35 + selectedFacets, 36 + isFacetSelected, 37 + toggleFacet, 38 + selectCategory, 39 + deselectCategory, 40 + selectAll, 41 + deselectAll, 42 + isAllSelected, 43 + isNoneSelected, 44 + allFacets, 45 + } = useFacetSelection() 46 + 47 + // Sync values to captured refs 48 + watchEffect(() => { 49 + capturedSelectedFacets.value = [...selectedFacets.value] 50 + capturedIsAllSelected.value = isAllSelected.value 51 + capturedIsNoneSelected.value = isNoneSelected.value 52 + }) 53 + 54 + capturedIsFacetSelected = isFacetSelected 55 + capturedToggleFacet = toggleFacet 56 + capturedSelectCategory = selectCategory 57 + capturedDeselectCategory = deselectCategory 58 + capturedSelectAll = selectAll 59 + capturedDeselectAll = deselectAll 60 + capturedAllFacets = allFacets 61 + 62 + return () => h('div') 63 + }, 64 + }) 65 + 66 + await mountSuspended(WrapperComponent) 67 + 68 + return { 69 + selectedFacets: capturedSelectedFacets, 70 + isFacetSelected: (facet: ComparisonFacet) => capturedIsFacetSelected(facet), 71 + toggleFacet: (facet: ComparisonFacet) => capturedToggleFacet(facet), 72 + selectCategory: (category: string) => capturedSelectCategory(category), 73 + deselectCategory: (category: string) => capturedDeselectCategory(category), 74 + selectAll: () => capturedSelectAll(), 75 + deselectAll: () => capturedDeselectAll(), 76 + isAllSelected: capturedIsAllSelected, 77 + isNoneSelected: capturedIsNoneSelected, 78 + allFacets: capturedAllFacets!, 79 + } 80 + } 81 + 11 82 describe('useFacetSelection', () => { 12 83 beforeEach(() => { 13 84 mockRouteQuery.value = '' 14 85 }) 15 86 16 - it('returns DEFAULT_FACETS when no query param', () => { 17 - const { selectedFacets } = useFacetSelection() 87 + it('returns DEFAULT_FACETS when no query param', async () => { 88 + const { isFacetSelected } = await useFacetSelectionInComponent() 18 89 19 - expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 90 + // All default facets should be selected 91 + for (const facet of DEFAULT_FACETS) { 92 + expect(isFacetSelected(facet)).toBe(true) 93 + } 20 94 }) 21 95 22 - it('parses facets from query param', () => { 96 + it('parses facets from query param', async () => { 23 97 mockRouteQuery.value = 'downloads,types,license' 24 98 25 - const { selectedFacets } = useFacetSelection() 99 + const { isFacetSelected } = await useFacetSelectionInComponent() 26 100 27 - expect(selectedFacets.value).toContain('downloads') 28 - expect(selectedFacets.value).toContain('types') 29 - expect(selectedFacets.value).toContain('license') 101 + expect(isFacetSelected('downloads')).toBe(true) 102 + expect(isFacetSelected('types')).toBe(true) 103 + expect(isFacetSelected('license')).toBe(true) 104 + expect(isFacetSelected('packageSize')).toBe(false) 30 105 }) 31 106 32 - it('filters out invalid facets from query', () => { 107 + it('filters out invalid facets from query', async () => { 33 108 mockRouteQuery.value = 'downloads,invalidFacet,types' 34 109 35 - const { selectedFacets } = useFacetSelection() 110 + const { isFacetSelected } = await useFacetSelectionInComponent() 36 111 37 - expect(selectedFacets.value).toContain('downloads') 38 - expect(selectedFacets.value).toContain('types') 39 - expect(selectedFacets.value).not.toContain('invalidFacet') 112 + expect(isFacetSelected('downloads')).toBe(true) 113 + expect(isFacetSelected('types')).toBe(true) 40 114 }) 41 115 42 - it('filters out comingSoon facets from query', () => { 116 + it('filters out comingSoon facets from query', async () => { 43 117 mockRouteQuery.value = 'downloads,totalDependencies,types' 44 118 45 - const { selectedFacets } = useFacetSelection() 119 + const { isFacetSelected } = await useFacetSelectionInComponent() 46 120 47 - expect(selectedFacets.value).toContain('downloads') 48 - expect(selectedFacets.value).toContain('types') 49 - expect(selectedFacets.value).not.toContain('totalDependencies') 121 + expect(isFacetSelected('downloads')).toBe(true) 122 + expect(isFacetSelected('types')).toBe(true) 123 + expect(isFacetSelected('totalDependencies')).toBe(false) 50 124 }) 51 125 52 - it('falls back to DEFAULT_FACETS if all parsed facets are invalid', () => { 126 + it('falls back to DEFAULT_FACETS if all parsed facets are invalid', async () => { 53 127 mockRouteQuery.value = 'invalidFacet1,invalidFacet2' 54 128 55 - const { selectedFacets } = useFacetSelection() 129 + const { isFacetSelected } = await useFacetSelectionInComponent() 130 + 131 + // All default facets should be selected when query is invalid 132 + for (const facet of DEFAULT_FACETS) { 133 + expect(isFacetSelected(facet)).toBe(true) 134 + } 135 + }) 136 + 137 + describe('selectedFacets enriched data', () => { 138 + it('includes label and description for each facet', async () => { 139 + mockRouteQuery.value = 'downloads,types' 140 + 141 + const { selectedFacets } = await useFacetSelectionInComponent() 142 + 143 + for (const facet of selectedFacets.value) { 144 + expect(facet.id).toBeDefined() 145 + expect(facet.label).toBeDefined() 146 + expect(facet.description).toBeDefined() 147 + expect(facet.category).toBeDefined() 148 + } 149 + }) 150 + 151 + it('includes category info for each facet', async () => { 152 + mockRouteQuery.value = 'downloads,packageSize' 56 153 57 - expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 154 + const { selectedFacets } = await useFacetSelectionInComponent() 155 + 156 + const downloadsFacet = selectedFacets.value.find(f => f.id === 'downloads') 157 + const packageSizeFacet = selectedFacets.value.find(f => f.id === 'packageSize') 158 + 159 + expect(downloadsFacet?.category).toBe('health') 160 + expect(packageSizeFacet?.category).toBe('performance') 161 + }) 58 162 }) 59 163 60 164 describe('isFacetSelected', () => { 61 - it('returns true for selected facets', () => { 165 + it('returns true for selected facets', async () => { 62 166 mockRouteQuery.value = 'downloads,types' 63 167 64 - const { isFacetSelected } = useFacetSelection() 168 + const { isFacetSelected } = await useFacetSelectionInComponent() 65 169 66 170 expect(isFacetSelected('downloads')).toBe(true) 67 171 expect(isFacetSelected('types')).toBe(true) 68 172 }) 69 173 70 - it('returns false for unselected facets', () => { 174 + it('returns false for unselected facets', async () => { 71 175 mockRouteQuery.value = 'downloads,types' 72 176 73 - const { isFacetSelected } = useFacetSelection() 177 + const { isFacetSelected } = await useFacetSelectionInComponent() 74 178 75 179 expect(isFacetSelected('license')).toBe(false) 76 180 expect(isFacetSelected('engines')).toBe(false) ··· 78 182 }) 79 183 80 184 describe('toggleFacet', () => { 81 - it('adds facet when not selected', () => { 185 + it('adds facet when not selected', async () => { 82 186 mockRouteQuery.value = 'downloads' 83 187 84 - const { selectedFacets, toggleFacet } = useFacetSelection() 188 + const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent() 85 189 86 190 toggleFacet('types') 87 191 88 - expect(selectedFacets.value).toContain('downloads') 89 - expect(selectedFacets.value).toContain('types') 192 + expect(isFacetSelected('downloads')).toBe(true) 193 + expect(isFacetSelected('types')).toBe(true) 90 194 }) 91 195 92 - it('removes facet when selected', () => { 196 + it('removes facet when selected', async () => { 93 197 mockRouteQuery.value = 'downloads,types' 94 198 95 - const { selectedFacets, toggleFacet } = useFacetSelection() 199 + const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent() 96 200 97 201 toggleFacet('types') 98 202 99 - expect(selectedFacets.value).toContain('downloads') 100 - expect(selectedFacets.value).not.toContain('types') 203 + expect(isFacetSelected('downloads')).toBe(true) 204 + expect(isFacetSelected('types')).toBe(false) 101 205 }) 102 206 103 - it('does not remove last facet', () => { 207 + it('does not remove last facet', async () => { 104 208 mockRouteQuery.value = 'downloads' 105 209 106 - const { selectedFacets, toggleFacet } = useFacetSelection() 210 + const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent() 107 211 108 212 toggleFacet('downloads') 109 213 110 - expect(selectedFacets.value).toContain('downloads') 111 - expect(selectedFacets.value.length).toBe(1) 214 + // Should still be selected (can't remove the last one) 215 + expect(isFacetSelected('downloads')).toBe(true) 112 216 }) 113 217 }) 114 218 115 219 describe('selectCategory', () => { 116 - it('selects all facets in a category', () => { 220 + it('selects all facets in a category', async () => { 117 221 mockRouteQuery.value = 'downloads' 118 222 119 - const { selectedFacets, selectCategory } = useFacetSelection() 223 + const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent() 120 224 121 225 selectCategory('performance') 122 226 ··· 124 228 f => f !== 'totalDependencies', // comingSoon facet 125 229 ) 126 230 for (const facet of performanceFacets) { 127 - expect(selectedFacets.value).toContain(facet) 231 + expect(isFacetSelected(facet)).toBe(true) 128 232 } 129 233 }) 130 234 131 - it('preserves existing selections from other categories', () => { 235 + it('preserves existing selections from other categories', async () => { 132 236 mockRouteQuery.value = 'downloads,license' 133 237 134 - const { selectedFacets, selectCategory } = useFacetSelection() 238 + const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent() 135 239 136 240 selectCategory('compatibility') 137 241 138 - expect(selectedFacets.value).toContain('downloads') 139 - expect(selectedFacets.value).toContain('license') 242 + expect(isFacetSelected('downloads')).toBe(true) 243 + expect(isFacetSelected('license')).toBe(true) 140 244 }) 141 245 }) 142 246 143 247 describe('deselectCategory', () => { 144 - it('deselects all facets in a category', () => { 248 + it('deselects all facets in a category', async () => { 145 249 mockRouteQuery.value = '' 146 - const { selectedFacets, deselectCategory } = useFacetSelection() 250 + const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent() 147 251 148 252 deselectCategory('performance') 149 253 ··· 151 255 f => f !== 'totalDependencies', 152 256 ) 153 257 for (const facet of nonComingSoonPerformanceFacets) { 154 - expect(selectedFacets.value).not.toContain(facet) 258 + expect(isFacetSelected(facet)).toBe(false) 155 259 } 156 260 }) 157 261 158 - it('does not deselect if it would leave no facets', () => { 262 + it('does not deselect if it would leave no facets', async () => { 159 263 mockRouteQuery.value = 'packageSize,installSize' 160 264 161 - const { selectedFacets, deselectCategory } = useFacetSelection() 265 + const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent() 162 266 163 267 deselectCategory('performance') 164 268 165 - // Should still have at least one facet 166 - expect(selectedFacets.value.length).toBeGreaterThan(0) 269 + // Should still have at least one facet selected - since we can't 270 + // deselect all, the original selection should remain 271 + expect(isFacetSelected('packageSize') || isFacetSelected('installSize')).toBe(true) 167 272 }) 168 273 }) 169 274 170 275 describe('selectAll', () => { 171 - it('selects all default facets', () => { 276 + it('selects all default facets', async () => { 172 277 mockRouteQuery.value = 'downloads' 173 278 174 - const { selectedFacets, selectAll } = useFacetSelection() 279 + const { isFacetSelected, selectAll } = await useFacetSelectionInComponent() 175 280 176 281 selectAll() 177 282 178 - expect(selectedFacets.value).toEqual(DEFAULT_FACETS) 283 + for (const facet of DEFAULT_FACETS) { 284 + expect(isFacetSelected(facet)).toBe(true) 285 + } 179 286 }) 180 287 }) 181 288 182 289 describe('deselectAll', () => { 183 - it('keeps only the first default facet', () => { 290 + it('keeps only the first default facet', async () => { 184 291 mockRouteQuery.value = '' 185 292 186 - const { selectedFacets, deselectAll } = useFacetSelection() 293 + const { isFacetSelected, deselectAll } = await useFacetSelectionInComponent() 187 294 188 295 deselectAll() 189 296 190 - expect(selectedFacets.value).toHaveLength(1) 191 - expect(selectedFacets.value[0]).toBe(DEFAULT_FACETS[0]) 297 + // Only the first default facet should be selected 298 + expect(isFacetSelected(DEFAULT_FACETS[0]!)).toBe(true) 299 + // Second one should not be selected 300 + expect(isFacetSelected(DEFAULT_FACETS[1]!)).toBe(false) 192 301 }) 193 302 }) 194 303 195 304 describe('isAllSelected', () => { 196 - it('returns true when all facets selected', () => { 305 + it('returns true when all facets selected', async () => { 197 306 mockRouteQuery.value = '' 198 307 199 - const { isAllSelected } = useFacetSelection() 308 + const { isAllSelected } = await useFacetSelectionInComponent() 200 309 201 310 expect(isAllSelected.value).toBe(true) 202 311 }) 203 312 204 - it('returns false when not all facets selected', () => { 313 + it('returns false when not all facets selected', async () => { 205 314 mockRouteQuery.value = 'downloads,types' 206 315 207 - const { isAllSelected } = useFacetSelection() 316 + const { isAllSelected } = await useFacetSelectionInComponent() 208 317 209 318 expect(isAllSelected.value).toBe(false) 210 319 }) 211 320 }) 212 321 213 322 describe('isNoneSelected', () => { 214 - it('returns true when only one facet selected', () => { 323 + it('returns true when only one facet selected', async () => { 215 324 mockRouteQuery.value = 'downloads' 216 325 217 - const { isNoneSelected } = useFacetSelection() 326 + const { isNoneSelected } = await useFacetSelectionInComponent() 218 327 219 328 expect(isNoneSelected.value).toBe(true) 220 329 }) 221 330 222 - it('returns false when multiple facets selected', () => { 331 + it('returns false when multiple facets selected', async () => { 223 332 mockRouteQuery.value = 'downloads,types' 224 333 225 - const { isNoneSelected } = useFacetSelection() 334 + const { isNoneSelected } = await useFacetSelectionInComponent() 226 335 227 336 expect(isNoneSelected.value).toBe(false) 228 337 }) 229 338 }) 230 339 231 340 describe('URL param behavior', () => { 232 - it('clears URL param when selecting all defaults', () => { 341 + it('clears URL param when selecting all defaults', async () => { 233 342 mockRouteQuery.value = 'downloads,types' 234 343 235 - const { selectAll } = useFacetSelection() 344 + const { selectAll } = await useFacetSelectionInComponent() 236 345 237 346 selectAll() 238 347 ··· 240 349 expect(mockRouteQuery.value).toBe('') 241 350 }) 242 351 243 - it('sets URL param when selecting subset of facets', () => { 352 + it('sets URL param when selecting subset of facets via toggleFacet', async () => { 244 353 mockRouteQuery.value = '' 245 354 246 - const { selectedFacets } = useFacetSelection() 355 + const { toggleFacet, deselectAll } = await useFacetSelectionInComponent() 247 356 248 - selectedFacets.value = ['downloads', 'types'] 357 + // Start with one facet, then add another 358 + deselectAll() 359 + toggleFacet('types') 249 360 250 - expect(mockRouteQuery.value).toBe('downloads,types') 361 + expect(mockRouteQuery.value).toContain('types') 251 362 }) 252 363 }) 253 364 254 365 describe('allFacets export', () => { 255 - it('exports allFacets array', () => { 256 - const { allFacets } = useFacetSelection() 366 + it('exports allFacets array', async () => { 367 + const { allFacets } = await useFacetSelectionInComponent() 257 368 258 369 expect(Array.isArray(allFacets)).toBe(true) 259 370 expect(allFacets.length).toBeGreaterThan(0) 260 371 }) 261 372 262 - it('allFacets includes all facets including comingSoon', () => { 263 - const { allFacets } = useFacetSelection() 373 + it('allFacets includes all facets including comingSoon', async () => { 374 + const { allFacets } = await useFacetSelectionInComponent() 264 375 265 376 expect(allFacets).toContain('totalDependencies') 266 377 }) 267 378 }) 268 379 269 380 describe('whitespace handling', () => { 270 - it('trims whitespace from facet names in query', () => { 381 + it('trims whitespace from facet names in query', async () => { 271 382 mockRouteQuery.value = ' downloads , types , license ' 272 383 273 - const { selectedFacets } = useFacetSelection() 384 + const { isFacetSelected } = await useFacetSelectionInComponent() 274 385 275 - expect(selectedFacets.value).toContain('downloads') 276 - expect(selectedFacets.value).toContain('types') 277 - expect(selectedFacets.value).toContain('license') 386 + expect(isFacetSelected('downloads')).toBe(true) 387 + expect(isFacetSelected('types')).toBe(true) 388 + expect(isFacetSelected('license')).toBe(true) 278 389 }) 279 390 }) 280 391 281 392 describe('duplicate handling', () => { 282 - it('handles duplicate facets in query by deduplication via Set', () => { 393 + it('handles duplicate facets in query by deduplication via Set', async () => { 283 394 // When adding facets, the code uses Set for deduplication 284 395 mockRouteQuery.value = 'downloads' 285 396 286 - const { selectedFacets, selectCategory } = useFacetSelection() 397 + const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent() 287 398 288 399 // downloads is in health category, selecting health should dedupe 289 400 selectCategory('health') 290 401 291 - // Count occurrences of downloads 292 - const downloadsCount = selectedFacets.value.filter(f => f === 'downloads').length 293 - expect(downloadsCount).toBe(1) 402 + // downloads should be selected exactly once (verified by isFacetSelected working) 403 + expect(isFacetSelected('downloads')).toBe(true) 294 404 }) 295 405 }) 296 406 297 407 describe('multiple category operations', () => { 298 - it('can select multiple categories', () => { 408 + it('can select multiple categories', async () => { 299 409 mockRouteQuery.value = 'downloads' 300 410 301 - const { selectedFacets, selectCategory } = useFacetSelection() 411 + const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent() 302 412 303 413 selectCategory('performance') 304 414 selectCategory('security') 305 415 306 416 // 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') 417 + expect(isFacetSelected('packageSize')).toBe(true) 418 + expect(isFacetSelected('license')).toBe(true) 419 + expect(isFacetSelected('downloads')).toBe(true) 310 420 }) 311 421 312 - it('can deselect multiple categories', () => { 422 + it('can deselect multiple categories', async () => { 313 423 mockRouteQuery.value = '' 314 424 315 - const { selectedFacets, deselectCategory } = useFacetSelection() 425 + const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent() 316 426 317 427 deselectCategory('performance') 318 428 deselectCategory('health') 319 429 320 430 // Should not have performance or health facets 321 - expect(selectedFacets.value).not.toContain('packageSize') 322 - expect(selectedFacets.value).not.toContain('downloads') 431 + expect(isFacetSelected('packageSize')).toBe(false) 432 + expect(isFacetSelected('downloads')).toBe(false) 323 433 }) 324 434 }) 325 435 })