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

feat: add charts to compare page (#846)

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

authored by

Alec Lloyd Probert
Daniel Roe
coderabbitai[bot]
and committed by
GitHub
36999892 6383f8a6

+626 -285
+13
app/components/Compare/LineChart.vue
··· 1 + <script setup lang="ts"> 2 + import DownloadAnalytics from '../Package/DownloadAnalytics.vue' 3 + 4 + const { packages } = defineProps<{ 5 + packages: string[] 6 + }>() 7 + </script> 8 + 9 + <template> 10 + <div class="font-mono"> 11 + <DownloadAnalytics :package-names="packages" :in-modal="false" /> 12 + </div> 13 + </template>
+447 -256
app/components/Package/DownloadAnalytics.vue
··· 4 4 import { useDebounceFn, useElementSize } from '@vueuse/core' 5 5 import { useCssVariables } from '~/composables/useColors' 6 6 import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '~/utils/colors' 7 + import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' 7 8 8 9 const props = defineProps<{ 9 - weeklyDownloads: WeeklyDownloadPoint[] 10 + // For single package downloads history 11 + weeklyDownloads?: WeeklyDownloadPoint[] 10 12 inModal?: boolean 11 - packageName: string 12 - createdIso: string | null 13 + 14 + /** 15 + * Backward compatible single package mode. 16 + * Used when `weeklyDownloads` is provided. 17 + */ 18 + packageName?: string 19 + 20 + /** 21 + * Multi-package mode. 22 + * Used when `weeklyDownloads` is not provided. 23 + */ 24 + packageNames?: string[] 25 + createdIso?: string | null 13 26 }>() 27 + 28 + const shouldFetch = computed(() => true) 14 29 15 30 const { locale } = useI18n() 16 31 const { accentColors, selectedAccentColor } = useAccentColor() ··· 20 35 21 36 const { width } = useElementSize(rootEl) 22 37 23 - onMounted(() => { 38 + onMounted(async () => { 24 39 rootEl.value = document.documentElement 25 40 resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 41 + 42 + initDateRangeFromWeekly() 43 + initDateRangeForMultiPackageWeekly52() 44 + initDateRangeFallbackClient() 45 + 46 + await nextTick() 47 + isMounted.value = true 48 + 49 + loadNow() 26 50 }) 27 51 28 52 const { colors } = useCssVariables( ··· 30 54 { 31 55 element: rootEl, 32 56 watchHtmlAttributes: true, 33 - watchResize: false, // set to true only if a var changes color on resize 57 + watchResize: false, 34 58 }, 35 59 ) 36 60 ··· 60 84 }) 61 85 62 86 const mobileBreakpointWidth = 640 63 - 64 - const isMobile = computed(() => { 65 - return width.value > 0 && width.value < mobileBreakpointWidth 66 - }) 87 + const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) 67 88 68 89 type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' 69 90 type EvolutionData = ··· 122 143 function formatXyDataset( 123 144 selectedGranularity: ChartTimeGranularity, 124 145 dataset: EvolutionData, 146 + seriesName: string, 125 147 ): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } { 126 148 if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) { 127 149 return { 128 150 dataset: [ 129 151 { 130 - name: props.packageName, 152 + name: seriesName, 131 153 type: 'line', 132 154 series: dataset.map(d => d.downloads), 133 155 color: accent.value, ··· 140 162 return { 141 163 dataset: [ 142 164 { 143 - name: props.packageName, 165 + name: seriesName, 144 166 type: 'line', 145 167 series: dataset.map(d => d.downloads), 146 168 color: accent.value, ··· 153 175 return { 154 176 dataset: [ 155 177 { 156 - name: props.packageName, 178 + name: seriesName, 157 179 type: 'line', 158 180 series: dataset.map(d => d.downloads), 159 181 color: accent.value, ··· 166 188 return { 167 189 dataset: [ 168 190 { 169 - name: props.packageName, 191 + name: seriesName, 170 192 type: 'line', 171 193 series: dataset.map(d => d.downloads), 172 194 color: accent.value, ··· 178 200 return { dataset: null, dates: [] } 179 201 } 180 202 203 + function extractSeriesPoints( 204 + selectedGranularity: ChartTimeGranularity, 205 + dataset: EvolutionData, 206 + ): Array<{ timestamp: number; downloads: number }> { 207 + if (selectedGranularity === 'weekly' && isWeeklyDataset(dataset)) { 208 + return dataset.map(d => ({ timestamp: d.timestampEnd, downloads: d.downloads })) 209 + } 210 + if (selectedGranularity === 'daily' && isDailyDataset(dataset)) { 211 + return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 212 + } 213 + if (selectedGranularity === 'monthly' && isMonthlyDataset(dataset)) { 214 + return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 215 + } 216 + if (selectedGranularity === 'yearly' && isYearlyDataset(dataset)) { 217 + return dataset.map(d => ({ timestamp: d.timestamp, downloads: d.downloads })) 218 + } 219 + return [] 220 + } 221 + 181 222 function toIsoDateOnly(value: string): string { 182 223 return value.slice(0, 10) 183 224 } 184 - 185 225 function isValidIsoDateOnly(value: string): boolean { 186 226 return /^\d{4}-\d{2}-\d{2}$/.test(value) 187 227 } 188 - 189 228 function safeMin(a: string, b: string): string { 190 229 return a.localeCompare(b) <= 0 ? a : b 191 230 } 192 - 193 231 function safeMax(a: string, b: string): string { 194 232 return a.localeCompare(b) >= 0 ? a : b 195 233 } 196 234 197 235 /** 198 - * Two-phase state: 199 - * - selectedGranularity: immediate UI 200 - * - displayedGranularity: only updated once data is ready 236 + * Multi-package mode detection: 237 + * packageNames has entries, and packageName is not set. 201 238 */ 239 + const isMultiPackageMode = computed(() => { 240 + const names = (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean) 241 + const single = String(props.packageName ?? '').trim() 242 + return names.length > 0 && !single 243 + }) 244 + 245 + const effectivePackageNames = computed<string[]>(() => { 246 + if (isMultiPackageMode.value) 247 + return (props.packageNames ?? []).map(n => String(n).trim()).filter(Boolean) 248 + const single = String(props.packageName ?? '').trim() 249 + return single ? [single] : [] 250 + }) 251 + 252 + const xAxisLabel = computed(() => { 253 + if (!isMultiPackageMode.value) return props.packageName ?? '' 254 + const names = effectivePackageNames.value 255 + if (names.length === 1) return names[0] 256 + return 'packages' 257 + }) 258 + 202 259 const selectedGranularity = shallowRef<ChartTimeGranularity>('weekly') 203 260 const displayedGranularity = shallowRef<ChartTimeGranularity>('weekly') 204 261 205 - /** 206 - * Date range inputs. 207 - * They are initialized from the current effective range: 208 - * - weekly: from weeklyDownloads first -> weekStart/weekEnd 209 - * - fallback: last 30 days ending yesterday (client-side) 210 - */ 211 262 const startDate = shallowRef<string>('') // YYYY-MM-DD 212 263 const endDate = shallowRef<string>('') // YYYY-MM-DD 213 264 const hasUserEditedDates = shallowRef(false) ··· 243 294 if (!endDate.value) endDate.value = end 244 295 } 245 296 297 + function toUtcDateOnly(date: Date): string { 298 + return date.toISOString().slice(0, 10) 299 + } 300 + function addUtcDays(date: Date, days: number): Date { 301 + const next = new Date(date) 302 + next.setUTCDate(next.getUTCDate() + days) 303 + return next 304 + } 305 + function initDateRangeForMultiPackageWeekly52() { 306 + if (hasUserEditedDates.value) return 307 + if (!import.meta.client) return 308 + if (!isMultiPackageMode.value) return 309 + if (startDate.value && endDate.value) return 310 + 311 + const today = new Date() 312 + const yesterday = new Date( 313 + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), 314 + ) 315 + 316 + endDate.value = toUtcDateOnly(yesterday) 317 + startDate.value = toUtcDateOnly(addUtcDays(yesterday, -(52 * 7) + 1)) 318 + } 319 + 246 320 watch( 247 - () => props.weeklyDownloads?.length, 321 + () => (props.packageNames ?? []).length, 248 322 () => { 249 - initDateRangeFromWeekly() 250 - initDateRangeFallbackClient() 323 + initDateRangeForMultiPackageWeekly52() 251 324 }, 252 325 { immediate: true }, 253 326 ) ··· 264 337 watch( 265 338 [startDate, endDate], 266 339 () => { 267 - // mark edited only when both have some value (prevents init watchers from flagging too early) 268 340 if (startDate.value || endDate.value) hasUserEditedDates.value = true 269 341 setInitialRangeIfEmpty() 270 342 }, ··· 276 348 return startDate.value !== initialStartDate.value || endDate.value !== initialEndDate.value 277 349 }) 278 350 351 + function resetDateRange() { 352 + hasUserEditedDates.value = false 353 + startDate.value = '' 354 + endDate.value = '' 355 + initDateRangeFromWeekly() 356 + initDateRangeForMultiPackageWeekly52() 357 + initDateRangeFallbackClient() 358 + } 359 + 279 360 const options = shallowRef< 280 361 | { granularity: 'day'; startDate?: string; endDate?: string } 281 362 | { granularity: 'week'; weeks: number; startDate?: string; endDate?: string } ··· 306 387 return next 307 388 } 308 389 390 + const { fetchPackageDownloadEvolution } = useCharts() 391 + 392 + const evolution = shallowRef<EvolutionData>(props.weeklyDownloads ?? []) 393 + const evolutionsByPackage = shallowRef<Record<string, EvolutionData>>({}) 394 + const pending = shallowRef(false) 395 + 396 + const isMounted = shallowRef(false) 397 + let requestToken = 0 398 + 309 399 watch( 310 400 [selectedGranularity, startDate, endDate], 311 401 ([granularityValue]) => { ··· 315 405 else if (granularityValue === 'monthly') 316 406 options.value = applyDateRange({ granularity: 'month', months: 24 }) 317 407 else options.value = applyDateRange({ granularity: 'year' }) 318 - }, 319 - { immediate: true }, 320 - ) 321 408 322 - const { fetchPackageDownloadEvolution } = useCharts() 409 + // Do not set pending during initial setup 410 + if (!isMounted.value) return 323 411 324 - const evolution = shallowRef<EvolutionData>(props.weeklyDownloads) 325 - const pending = shallowRef(false) 412 + const packageNames = effectivePackageNames.value 413 + if (!import.meta.client || !shouldFetch.value || !packageNames.length) { 414 + pending.value = false 415 + return 416 + } 326 417 327 - let lastRequestKey = '' 328 - let requestToken = 0 418 + const o = options.value as any 419 + const hasExplicitRange = Boolean(o.startDate || o.endDate) 329 420 330 - const debouncedLoad = useDebounceFn(() => { 331 - load() 332 - }, 1000) 421 + // Do not show loading when weeklyDownloads is already provided 422 + if ( 423 + !isMultiPackageMode.value && 424 + o.granularity === 'week' && 425 + props.weeklyDownloads?.length && 426 + !hasExplicitRange 427 + ) { 428 + pending.value = false 429 + return 430 + } 333 431 334 - async function load() { 432 + pending.value = true 433 + }, 434 + { immediate: true }, 435 + ) 436 + 437 + async function loadNow() { 335 438 if (!import.meta.client) return 336 - if (!props.inModal) return 439 + if (!shouldFetch.value) return 337 440 338 - const o = options.value 339 - const extraBase = 340 - o.granularity === 'week' 341 - ? `w:${String(o.weeks ?? '')}` 342 - : o.granularity === 'month' 343 - ? `m:${String(o.months ?? '')}` 344 - : '' 441 + const packageNames = effectivePackageNames.value 442 + if (!packageNames.length) return 345 443 346 - const startKey = (o as any).startDate ?? '' 347 - const endKey = (o as any).endDate ?? '' 348 - const requestKey = `${props.packageName}|${props.createdIso ?? ''}|${o.granularity}|${extraBase}|${startKey}|${endKey}` 444 + const currentToken = ++requestToken 445 + pending.value = true 349 446 350 - if (requestKey === lastRequestKey) return 351 - lastRequestKey = requestKey 447 + try { 448 + if (isMultiPackageMode.value) { 449 + const settled = await Promise.allSettled( 450 + packageNames.map(async pkg => { 451 + const result = await fetchPackageDownloadEvolution( 452 + pkg, 453 + props.createdIso ?? null, 454 + options.value, 455 + ) 456 + return { pkg, result: (result ?? []) as EvolutionData } 457 + }), 458 + ) 352 459 353 - const hasExplicitRange = Boolean((o as any).startDate || (o as any).endDate) 354 - if (o.granularity === 'week' && props.weeklyDownloads?.length && !hasExplicitRange) { 355 - evolution.value = props.weeklyDownloads 356 - pending.value = false 357 - displayedGranularity.value = 'weekly' 358 - return 359 - } 460 + if (currentToken !== requestToken) return 360 461 361 - pending.value = true 362 - const currentToken = ++requestToken 462 + const next: Record<string, EvolutionData> = {} 463 + for (const entry of settled) { 464 + if (entry.status === 'fulfilled') next[entry.value.pkg] = entry.value.result 465 + } 363 466 364 - try { 365 - const result = await fetchPackageDownloadEvolution( 366 - () => props.packageName, 367 - () => props.createdIso, 368 - () => o as any, // FIXME: any 369 - ) 467 + evolutionsByPackage.value = next 468 + displayedGranularity.value = selectedGranularity.value 469 + return 470 + } 370 471 472 + const pkg = packageNames[0] ?? '' 473 + if (!pkg) { 474 + evolution.value = [] 475 + displayedGranularity.value = selectedGranularity.value 476 + return 477 + } 478 + 479 + const o = options.value 480 + const hasExplicitRange = Boolean((o as any).startDate || (o as any).endDate) 481 + if (o.granularity === 'week' && props.weeklyDownloads?.length && !hasExplicitRange) { 482 + evolution.value = props.weeklyDownloads 483 + displayedGranularity.value = 'weekly' 484 + return 485 + } 486 + 487 + const result = await fetchPackageDownloadEvolution(pkg, props.createdIso ?? null, options.value) 371 488 if (currentToken !== requestToken) return 372 489 373 - evolution.value = (result as EvolutionData) ?? [] 490 + evolution.value = (result ?? []) as EvolutionData 374 491 displayedGranularity.value = selectedGranularity.value 375 492 } catch { 376 493 if (currentToken !== requestToken) return 377 - evolution.value = [] 494 + if (isMultiPackageMode.value) evolutionsByPackage.value = {} 495 + else evolution.value = [] 378 496 } finally { 379 - if (currentToken === requestToken) { 380 - pending.value = false 381 - } 497 + if (currentToken === requestToken) pending.value = false 382 498 } 383 499 } 384 500 385 - watch( 386 - () => props.inModal, 387 - () => { 388 - // modal open/close should be immediate 389 - load() 390 - }, 391 - { immediate: true }, 392 - ) 501 + const debouncedLoadNow = useDebounceFn(() => { 502 + loadNow() 503 + }, 1000) 393 504 394 - watch( 395 - () => [ 396 - props.packageName, 397 - props.createdIso, 398 - options.value.granularity, 399 - (options.value as any).weeks, 400 - (options.value as any).months, 401 - ], 402 - () => { 403 - // changing package or granularity should be immediate 404 - load() 405 - }, 406 - { immediate: true }, 407 - ) 505 + const fetchTriggerKey = computed(() => { 506 + const names = effectivePackageNames.value.join(',') 507 + const o = options.value as any 508 + return [ 509 + shouldFetch.value ? '1' : '0', 510 + isMultiPackageMode.value ? 'M' : 'S', 511 + names, 512 + String(props.createdIso ?? ''), 513 + String(o.granularity ?? ''), 514 + String(o.weeks ?? ''), 515 + String(o.months ?? ''), 516 + String(o.startDate ?? ''), 517 + String(o.endDate ?? ''), 518 + ].join('|') 519 + }) 408 520 409 521 watch( 410 - () => [(options.value as any).startDate, (options.value as any).endDate], 522 + () => fetchTriggerKey.value, 411 523 () => { 412 - // date typing / picking should be debounced 413 - debouncedLoad() 524 + if (!import.meta.client) return 525 + if (!isMounted.value) return 526 + debouncedLoadNow() 414 527 }, 415 - { immediate: true }, 528 + { flush: 'post' }, 416 529 ) 417 530 418 - const effectiveData = computed<EvolutionData>(() => { 531 + const effectiveDataSingle = computed<EvolutionData>(() => { 419 532 if (displayedGranularity.value === 'weekly' && props.weeklyDownloads?.length) { 420 533 if (isWeeklyDataset(evolution.value) && evolution.value.length) return evolution.value 421 534 return props.weeklyDownloads ··· 424 537 }) 425 538 426 539 const chartData = computed<{ dataset: VueUiXyDatasetItem[] | null; dates: number[] }>(() => { 427 - return formatXyDataset(displayedGranularity.value, effectiveData.value) 540 + if (!isMultiPackageMode.value) { 541 + const pkg = effectivePackageNames.value[0] ?? props.packageName ?? '' 542 + return formatXyDataset(displayedGranularity.value, effectiveDataSingle.value, pkg) 543 + } 544 + 545 + const names = effectivePackageNames.value 546 + const granularity = displayedGranularity.value 547 + 548 + const timestampSet = new Set<number>() 549 + const pointsByPackage = new Map<string, Array<{ timestamp: number; downloads: number }>>() 550 + 551 + for (const pkg of names) { 552 + const data = evolutionsByPackage.value[pkg] ?? [] 553 + const points = extractSeriesPoints(granularity, data) 554 + pointsByPackage.set(pkg, points) 555 + for (const p of points) timestampSet.add(p.timestamp) 556 + } 557 + 558 + const dates = Array.from(timestampSet).sort((a, b) => a - b) 559 + if (!dates.length) return { dataset: null, dates: [] } 560 + 561 + const dataset: VueUiXyDatasetItem[] = names.map((pkg, index) => { 562 + const points = pointsByPackage.get(pkg) ?? [] 563 + const map = new Map<number, number>() 564 + for (const p of points) map.set(p.timestamp, p.downloads) 565 + 566 + const series = dates.map(t => map.get(t) ?? 0) 567 + 568 + const item: VueUiXyDatasetItem = { name: pkg, type: 'line', series } as VueUiXyDatasetItem 569 + 570 + if (isListedFramework(pkg)) { 571 + item.color = getFrameworkColor(pkg) 572 + } 573 + // Other packages default to built-in palette 574 + return item 575 + }) 576 + 577 + return { dataset, dates } 428 578 }) 429 579 430 580 const formatter = ({ value }: { value: number }) => formatCompactNumber(value, { decimals: 1 }) ··· 439 589 440 590 const datetimeFormatterOptions = computed(() => { 441 591 return { 442 - daily: { 443 - year: 'yyyy-MM-dd', 444 - month: 'yyyy-MM-dd', 445 - day: 'yyyy-MM-dd', 446 - }, 447 - weekly: { 448 - year: 'yyyy-MM-dd', 449 - month: 'yyyy-MM-dd', 450 - day: 'yyyy-MM-dd', 451 - }, 452 - monthly: { 453 - year: 'MMM yyyy', 454 - month: 'MMM yyyy', 455 - day: 'MMM yyyy', 456 - }, 457 - yearly: { 458 - year: 'yyyy', 459 - month: 'yyyy', 460 - day: 'yyyy', 461 - }, 592 + daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, 593 + weekly: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, 594 + monthly: { year: 'MMM yyyy', month: 'MMM yyyy', day: 'MMM yyyy' }, 595 + yearly: { year: 'yyyy', month: 'yyyy', day: 'yyyy' }, 462 596 }[selectedGranularity.value] 463 597 }) 464 598 599 + const sanitise = (value: string) => 600 + value 601 + .replace(/^@/, '') 602 + .replace(/[\\/:"*?<>|]/g, '-') 603 + .replace(/\//g, '-') 604 + 605 + function buildExportFilename(extension: string): string { 606 + const g = selectedGranularity.value 607 + const range = `${startDate.value}_${endDate.value}` 608 + 609 + if (!isMultiPackageMode.value) { 610 + const name = effectivePackageNames.value[0] ?? props.packageName ?? 'package' 611 + return `${sanitise(name)}-${g}_${range}.${extension}` 612 + } 613 + 614 + const names = effectivePackageNames.value 615 + const label = names.length === 1 ? names[0] : names.join('_') 616 + return `${sanitise(label ?? '')}-${g}_${range}.${extension}` 617 + } 618 + 465 619 const config = computed(() => { 466 620 return { 467 621 theme: isDarkMode.value ? 'dark' : 'default', 468 622 chart: { 469 623 height: isMobile.value ? 950 : 600, 470 - padding: { 471 - bottom: 36, 472 - }, 624 + padding: { bottom: 36 }, 473 625 userOptions: { 474 - buttons: { 475 - pdf: false, 476 - labels: false, 477 - fullscreen: false, 478 - table: false, 479 - tooltip: false, 480 - }, 626 + buttons: { pdf: false, labels: false, fullscreen: false, table: false, tooltip: false }, 481 627 buttonTitles: { 482 628 csv: $t('package.downloads.download_file', { fileType: 'CSV' }), 483 629 img: $t('package.downloads.download_file', { fileType: 'PNG' }), ··· 486 632 }, 487 633 callbacks: { 488 634 img: ({ imageUri }: { imageUri: string }) => { 489 - loadFile( 490 - imageUri, 491 - `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, 492 - ) 635 + loadFile(imageUri, buildExportFilename('png')) 493 636 }, 494 637 csv: (csvStr: string) => { 495 - // Extract multiline date format template and replace newlines with spaces in CSV 496 - // This ensures CSV compatibility by converting multiline date ranges to single-line format 497 638 const PLACEHOLDER_CHAR = '\0' 498 639 const multilineDateTemplate = $t('package.downloads.date_range_multiline', { 499 640 start: PLACEHOLDER_CHAR, ··· 507 648 .replaceAll(`\n${multilineDateTemplate}`, ` ${multilineDateTemplate}`), 508 649 ]) 509 650 const url = URL.createObjectURL(blob) 510 - loadFile( 511 - url, 512 - `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, 513 - ) 651 + loadFile(url, buildExportFilename('csv')) 514 652 URL.revokeObjectURL(url) 515 653 }, 516 654 svg: ({ blob }: { blob: Blob }) => { 517 655 const url = URL.createObjectURL(blob) 518 - loadFile( 519 - url, 520 - `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, 521 - ) 656 + loadFile(url, buildExportFilename('svg')) 522 657 URL.revokeObjectURL(url) 523 658 }, 524 659 }, ··· 532 667 yLabel: $t('package.downloads.y_axis_label', { 533 668 granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), 534 669 }), 535 - xLabel: props.packageName, 670 + xLabel: isMultiPackageMode.value ? '' : xAxisLabel.value, // for multiple series, names are displayed in the chart's legend 536 671 yLabelOffsetX: 12, 537 672 fontSize: isMobile.value ? 32 : 24, 538 673 }, ··· 549 684 yAxis: { 550 685 formatter, 551 686 useNiceScale: true, 687 + gap: 24, // vertical gap between individual series in stacked mode 552 688 }, 553 689 }, 554 690 }, ··· 557 693 backgroundColor: colors.value.bgElevated, 558 694 color: colors.value.fg, 559 695 fontSize: 16, 560 - circleMarker: { 561 - radius: 3, 562 - color: colors.value.border, 563 - }, 696 + circleMarker: { radius: 3, color: colors.value.border }, 564 697 useDefaultFormat: true, 565 698 timeFormat: 'yyyy-MM-dd HH:mm:ss', 566 699 }, 567 - highlighter: { 568 - useLine: true, 569 - }, 570 - legend: { 571 - show: false, // As long as a single package is displayed 572 - }, 700 + highlighter: { useLine: true }, 701 + legend: { show: false, position: 'top' }, 573 702 tooltip: { 574 - teleportTo: '#chart-modal', 703 + teleportTo: props.inModal ? '#chart-modal' : undefined, 575 704 borderColor: 'transparent', 576 705 backdropFilter: false, 577 706 backgroundColor: 'transparent', 578 - customFormat: ({ datapoint }: { datapoint: Record<string, any> }) => { 707 + customFormat: ({ datapoint }: { datapoint: Record<string, any> | any[] }) => { 579 708 if (!datapoint) return '' 580 - const displayValue = formatter({ value: datapoint[0]?.value ?? 0 }) 581 - return `<div class="flex flex-col font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md"> 582 - <span class="text-xl text-[var(--fg)]">${displayValue}</span> 583 - </div> 584 - ` 709 + 710 + const items = Array.isArray(datapoint) ? datapoint : [datapoint[0]] 711 + const hasMultipleItems = items.length > 1 712 + 713 + const rows = items 714 + .map((d: any) => { 715 + const label = String(d?.name ?? '').trim() 716 + const raw = Number(d?.value ?? 0) 717 + const v = formatter({ value: Number.isFinite(raw) ? raw : 0 }) 718 + 719 + if (!hasMultipleItems) { 720 + // We don't need the name of the package in this case, since it is shown in the xAxis label 721 + return `<div> 722 + <span class="text-base text-[var(--fg)] font-mono tabular-nums">${v}</span> 723 + </div>` 724 + } 725 + 726 + return `<div class="grid grid-cols-[12px_minmax(0,1fr)_max-content] items-center gap-x-3"> 727 + <div class="w-3 h-3"> 728 + <svg viewBox="0 0 2 2" class="w-full h-full"> 729 + <rect x="0" y="0" width="2" height="2" rx="0.3" fill="${d.color}" /> 730 + </svg> 731 + </div> 732 + 733 + <span class="text-[10px] uppercase tracking-wide text-[var(--fg)]/70 truncate"> 734 + ${label} 735 + </span> 736 + 737 + <span class="text-base text-[var(--fg)] font-mono tabular-nums text-right"> 738 + ${v} 739 + </span> 740 + </div>` 741 + }) 742 + .join('') 743 + 744 + return `<div class="font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md"> 745 + <div class="${hasMultipleItems ? 'flex flex-col gap-2' : ''}"> 746 + ${rows} 747 + </div> 748 + </div>` 585 749 }, 586 750 }, 587 751 zoom: { ··· 607 771 </script> 608 772 609 773 <template> 610 - <div class="w-full relative" id="download-analytics"> 774 + <div class="w-full relative" id="download-analytics" :aria-busy="pending ? 'true' : 'false'"> 611 775 <div class="w-full mb-4 flex flex-col gap-3"> 612 - <!-- Mobile: stack vertically, Desktop: horizontal --> 613 776 <div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end"> 614 - <!-- Granularity --> 615 777 <div class="flex flex-col gap-1 sm:shrink-0"> 616 778 <label 617 779 for="granularity" ··· 624 786 <select 625 787 id="granularity" 626 788 v-model="selectedGranularity" 789 + :disabled="pending" 627 790 class="w-full px-2.5 py-1.75 bg-bg-subtle font-mono text-sm text-fg outline-none appearance-none focus-visible:outline-accent/70" 628 791 > 629 792 <option value="daily">{{ $t('package.downloads.granularity_daily') }}</option> ··· 634 797 </div> 635 798 </div> 636 799 637 - <!-- Date range inputs --> 638 800 <div class="grid grid-cols-2 gap-2 flex-1"> 639 801 <div class="flex flex-col gap-1"> 640 802 <label ··· 650 812 <input 651 813 id="startDate" 652 814 v-model="startDate" 815 + :disabled="pending" 653 816 type="date" 654 817 class="w-full min-w-0 bg-transparent font-mono text-sm text-fg outline-none [color-scheme:light] dark:[color-scheme:dark]" 655 818 /> ··· 670 833 <input 671 834 id="endDate" 672 835 v-model="endDate" 836 + :disabled="pending" 673 837 type="date" 674 838 class="w-full min-w-0 bg-transparent font-mono text-sm text-fg outline-none [color-scheme:light] dark:[color-scheme:dark]" 675 839 /> ··· 677 841 </div> 678 842 </div> 679 843 680 - <!-- Reset button --> 681 844 <button 682 845 v-if="showResetButton" 683 846 type="button" 684 847 aria-label="Reset date range" 685 848 class="self-end flex items-center justify-center px-2.5 py-1.75 border border-transparent rounded-md text-fg-subtle hover:text-fg transition-colors hover:border-border focus-visible:outline-accent/70 sm:mb-0" 686 - @click=" 687 - () => { 688 - hasUserEditedDates = false 689 - startDate = '' 690 - endDate = '' 691 - initDateRangeFromWeekly() 692 - initDateRangeFallbackClient() 693 - } 694 - " 849 + @click="resetDateRange" 695 850 > 696 851 <span class="i-carbon:reset w-5 h-5" aria-hidden="true" /> 697 852 </button> 698 853 </div> 699 854 </div> 700 855 701 - <ClientOnly v-if="inModal && chartData.dataset"> 702 - <VueUiXy :dataset="chartData.dataset" :config="config" class="[direction:ltr]"> 703 - <template #menuIcon="{ isOpen }"> 704 - <span v-if="isOpen" class="i-carbon:close w-6 h-6" aria-hidden="true" /> 705 - <span v-else class="i-carbon:overflow-menu-vertical w-6 h-6" aria-hidden="true" /> 706 - </template> 707 - <template #optionCsv> 708 - <span 709 - class="i-carbon:csv w-6 h-6 text-fg-subtle" 710 - style="pointer-events: none" 711 - aria-hidden="true" 712 - /> 713 - </template> 714 - <template #optionImg> 715 - <span 716 - class="i-carbon:png w-6 h-6 text-fg-subtle" 717 - style="pointer-events: none" 718 - aria-hidden="true" 719 - /> 720 - </template> 721 - <template #optionSvg> 722 - <span 723 - class="i-carbon:svg w-6 h-6 text-fg-subtle" 724 - style="pointer-events: none" 725 - aria-hidden="true" 726 - /> 727 - </template> 856 + <h2 id="download-analytics-title" class="sr-only"> 857 + {{ $t('package.downloads.title') }} 858 + </h2> 859 + 860 + <div role="region" aria-labelledby="download-analytics-title"> 861 + <ClientOnly v-if="chartData.dataset"> 862 + <div> 863 + <VueUiXy :dataset="chartData.dataset" :config="config" class="[direction:ltr]"> 864 + <!-- Custom legend for multiple series --> 865 + <template v-if="isMultiPackageMode" #legend="{ legend }"> 866 + <div class="flex gap-4 flex-wrap justify-center"> 867 + <button 868 + v-for="datapoint in legend" 869 + :key="datapoint.name" 870 + :aria-pressed="datapoint.isSegregated" 871 + :aria-label="datapoint.name" 872 + type="button" 873 + class="flex gap-1 place-items-center" 874 + @click="datapoint.segregate()" 875 + > 876 + <div class="h-3 w-3"> 877 + <svg viewBox="0 0 2 2" class="w-full"> 878 + <rect x="0" y="0" width="2" height="2" rx="0.3" :fill="datapoint.color" /> 879 + </svg> 880 + </div> 881 + <span 882 + :style="{ 883 + textDecoration: datapoint.isSegregated ? 'line-through' : undefined, 884 + }" 885 + > 886 + {{ datapoint.name }} 887 + </span> 888 + </button> 889 + </div> 890 + </template> 891 + 892 + <template #menuIcon="{ isOpen }"> 893 + <span v-if="isOpen" class="i-carbon:close w-6 h-6" aria-hidden="true" /> 894 + <span v-else class="i-carbon:overflow-menu-vertical w-6 h-6" aria-hidden="true" /> 895 + </template> 896 + <template #optionCsv> 897 + <span 898 + class="i-carbon:csv w-6 h-6 text-fg-subtle" 899 + style="pointer-events: none" 900 + aria-hidden="true" 901 + /> 902 + </template> 903 + <template #optionImg> 904 + <span 905 + class="i-carbon:png w-6 h-6 text-fg-subtle" 906 + style="pointer-events: none" 907 + aria-hidden="true" 908 + /> 909 + </template> 910 + <template #optionSvg> 911 + <span 912 + class="i-carbon:svg w-6 h-6 text-fg-subtle" 913 + style="pointer-events: none" 914 + aria-hidden="true" 915 + /> 916 + </template> 917 + 918 + <template #annotator-action-close> 919 + <span 920 + class="i-carbon:close w-6 h-6 text-fg-subtle" 921 + style="pointer-events: none" 922 + aria-hidden="true" 923 + /> 924 + </template> 925 + <template #annotator-action-color="{ color }"> 926 + <span class="i-carbon:color-palette w-6 h-6" :style="{ color }" aria-hidden="true" /> 927 + </template> 928 + <template #annotator-action-undo> 929 + <span 930 + class="i-carbon:undo w-6 h-6 text-fg-subtle" 931 + style="pointer-events: none" 932 + aria-hidden="true" 933 + /> 934 + </template> 935 + <template #annotator-action-redo> 936 + <span 937 + class="i-carbon:redo w-6 h-6 text-fg-subtle" 938 + style="pointer-events: none" 939 + aria-hidden="true" 940 + /> 941 + </template> 942 + <template #annotator-action-delete> 943 + <span 944 + class="i-carbon:trash-can w-6 h-6 text-fg-subtle" 945 + style="pointer-events: none" 946 + aria-hidden="true" 947 + /> 948 + </template> 949 + <template #optionAnnotator="{ isAnnotator }"> 950 + <span 951 + v-if="isAnnotator" 952 + class="i-carbon:edit-off w-6 h-6 text-fg-subtle" 953 + style="pointer-events: none" 954 + aria-hidden="true" 955 + /> 956 + <span 957 + v-else 958 + class="i-carbon:edit w-6 h-6 text-fg-subtle" 959 + style="pointer-events: none" 960 + aria-hidden="true" 961 + /> 962 + </template> 963 + </VueUiXy> 964 + </div> 728 965 729 - <template #annotator-action-close> 730 - <span 731 - class="i-carbon:close w-6 h-6 text-fg-subtle" 732 - style="pointer-events: none" 733 - aria-hidden="true" 734 - /> 966 + <template #fallback> 967 + <div class="min-h-[260px]" /> 735 968 </template> 736 - <template #annotator-action-color="{ color }"> 737 - <span class="i-carbon:color-palette w-6 h-6" :style="{ color }" aria-hidden="true" /> 738 - </template> 739 - <template #annotator-action-undo> 740 - <span 741 - class="i-carbon:undo w-6 h-6 text-fg-subtle" 742 - style="pointer-events: none" 743 - aria-hidden="true" 744 - /> 745 - </template> 746 - <template #annotator-action-redo> 747 - <span 748 - class="i-carbon:redo w-6 h-6 text-fg-subtle" 749 - style="pointer-events: none" 750 - aria-hidden="true" 751 - /> 752 - </template> 753 - <template #annotator-action-delete> 754 - <span 755 - class="i-carbon:trash-can w-6 h-6 text-fg-subtle" 756 - style="pointer-events: none" 757 - aria-hidden="true" 758 - /> 759 - </template> 760 - <template #optionAnnotator="{ isAnnotator }"> 761 - <span 762 - v-if="isAnnotator" 763 - class="i-carbon:edit-off w-6 h-6 text-fg-subtle" 764 - style="pointer-events: none" 765 - aria-hidden="true" 766 - /> 767 - <span 768 - v-else 769 - class="i-carbon:edit w-6 h-6 text-fg-subtle" 770 - style="pointer-events: none" 771 - aria-hidden="true" 772 - /> 773 - </template> 774 - </VueUiXy> 775 - <template #fallback> 776 - <div class="min-h-[260px]" /> 777 - </template> 778 - </ClientOnly> 969 + </ClientOnly> 970 + </div> 779 971 780 - <!-- Empty state when no chart data --> 781 972 <div 782 - v-if="inModal && !chartData.dataset && !pending" 973 + v-if="shouldFetch && !chartData.dataset && !pending" 783 974 class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm" 784 975 > 785 976 {{ $t('package.downloads.no_data') }}
+9
app/pages/compare.vue
··· 153 153 :headers="gridHeaders" 154 154 /> 155 155 </div> 156 + 157 + <h2 158 + id="comparison-heading" 159 + class="text-xs text-fg-subtle uppercase tracking-wider mb-4 mt-10" 160 + > 161 + {{ $t('package.downloads.title') }} 162 + </h2> 163 + 164 + <CompareLineChart :packages /> 156 165 </div> 157 166 158 167 <div v-else class="text-center py-12" role="alert">
+2 -13
app/pages/index.vue
··· 1 1 <script setup lang="ts"> 2 2 import { debounce } from 'perfect-debounce' 3 + import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' 3 4 4 5 const searchQuery = shallowRef('') 5 6 const searchInputRef = useTemplateRef('searchInputRef') 6 7 const { focused: isSearchFocused } = useFocus(searchInputRef) 7 - const frameworks = ref([ 8 - { name: 'nuxt', package: 'nuxt' }, 9 - { name: 'vue', package: 'vue' }, 10 - { name: 'nitro', package: 'nitro' }, 11 - { name: 'react', package: 'react' }, 12 - { name: 'svelte', package: 'svelte' }, 13 - { name: 'vite', package: 'vite' }, 14 - { name: 'next', package: 'next' }, 15 - { name: 'astro', package: 'astro' }, 16 - { name: 'typescript', package: 'typescript' }, 17 - { name: 'angular', package: '@angular/core' }, 18 - ]) 19 8 20 9 async function search() { 21 10 const query = searchQuery.value.trim() ··· 125 114 style="animation-delay: 0.3s" 126 115 > 127 116 <ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 list-none m-0 p-0"> 128 - <li v-for="framework in frameworks" :key="framework.name"> 117 + <li v-for="framework in SHOWCASED_FRAMEWORKS" :key="framework.name"> 129 118 <NuxtLink 130 119 :to="{ name: 'package', params: { package: [framework.package] } }" 131 120 class="link-subtle font-mono text-sm inline-flex items-center gap-2 group"
+64
app/utils/frameworks.ts
··· 1 + export type ShowcasedFramework = { 2 + name: string 3 + package: string 4 + color: string 5 + } 6 + 7 + export const SHOWCASED_FRAMEWORKS = [ 8 + { 9 + name: 'nuxt', 10 + package: 'nuxt', 11 + color: 'oklch(0.7862 0.192 155.63)', 12 + }, 13 + { name: 'vue', package: 'vue', color: 'oklch(0.7025 0.132 160.37)' }, 14 + { 15 + name: 'nitro', 16 + package: 'nitro', 17 + color: 'oklch(70.4% 0.191 22.216)', 18 + }, 19 + { 20 + name: 'react', 21 + package: 'react', 22 + color: 'oklch(0.832 0.1167 218.69)', 23 + }, 24 + { 25 + name: 'svelte', 26 + package: 'svelte', 27 + color: 'oklch(0.6917 0.1865 35.04)', 28 + }, 29 + { 30 + name: 'vite', 31 + package: 'vite', 32 + color: 'oklch(0.7484 0.1439 294.03)', 33 + }, 34 + { 35 + name: 'next', 36 + package: 'next', 37 + color: 'oklch(71.7% .1648 250.794)', 38 + }, 39 + { 40 + name: 'astro', 41 + package: 'astro', 42 + color: 'oklch(0.5295 0.2434 270.23)', 43 + }, 44 + { 45 + name: 'typescript', 46 + package: 'typescript', 47 + color: 'oklch(0.5671 0.1399 253.3)', 48 + }, 49 + { 50 + name: 'angular', 51 + package: '@angular/core', 52 + color: 'oklch(0.626 0.2663 310.4)', 53 + }, 54 + ] 55 + 56 + export type FrameworkPackageName = (typeof SHOWCASED_FRAMEWORKS)[number]['package'] 57 + 58 + export function getFrameworkColor(framework: FrameworkPackageName): string { 59 + return SHOWCASED_FRAMEWORKS.find(f => f.package === framework)!.color 60 + } 61 + 62 + export function isListedFramework(name: string): name is FrameworkPackageName { 63 + return SHOWCASED_FRAMEWORKS.some(f => f.package === name) 64 + }
+1 -1
package.json
··· 97 97 "vite-plugin-pwa": "1.2.0", 98 98 "vite-plus": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", 99 99 "vue": "3.5.27", 100 - "vue-data-ui": "3.14.3" 100 + "vue-data-ui": "3.14.5" 101 101 }, 102 102 "devDependencies": { 103 103 "@intlify/core-base": "11.2.8",
+5 -5
pnpm-lock.yaml
··· 195 195 specifier: 3.5.27 196 196 version: 3.5.27(typescript@5.9.3) 197 197 vue-data-ui: 198 - specifier: 3.14.3 199 - version: 3.14.3(vue@3.5.27(typescript@5.9.3)) 198 + specifier: 3.14.5 199 + version: 3.14.5(vue@3.5.27(typescript@5.9.3)) 200 200 devDependencies: 201 201 '@intlify/core-base': 202 202 specifier: 11.2.8 ··· 9276 9276 vue-component-type-helpers@3.2.4: 9277 9277 resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} 9278 9278 9279 - vue-data-ui@3.14.3: 9280 - resolution: {integrity: sha512-cpRxBWVKep8sfFGDh88lsdRMirEher2661NZQm/W0Jlsox0sGSJWITyj8+rlQrYi822oVDdVnWq8l8cJ7g/0BA==} 9279 + vue-data-ui@3.14.5: 9280 + resolution: {integrity: sha512-VjRJAHvnb0NFqrz/hB8cG4bNnOVzO0J3kJ1lE9Ir3dMFKA37R1lEriu7Q4tlgkoFLmsvhV/wfan2iiLFCBYKWA==} 9281 9281 peerDependencies: 9282 9282 jspdf: '>=3.0.1' 9283 9283 vue: '>=3.3.0' ··· 20704 20704 20705 20705 vue-component-type-helpers@3.2.4: {} 20706 20706 20707 - vue-data-ui@3.14.3(vue@3.5.27(typescript@5.9.3)): 20707 + vue-data-ui@3.14.5(vue@3.5.27(typescript@5.9.3)): 20708 20708 dependencies: 20709 20709 vue: 3.5.27(typescript@5.9.3) 20710 20710
+58 -10
test/nuxt/a11y.spec.ts
··· 3 3 import type { VueWrapper } from '@vue/test-utils' 4 4 import 'axe-core' 5 5 import type { AxeResults, RunOptions } from 'axe-core' 6 - import { afterEach, describe, expect, it } from 'vitest' 6 + import { afterEach, describe, expect, it, vi } from 'vitest' 7 7 8 8 // axe-core is a UMD module that exposes itself as window.axe in the browser 9 9 declare const axe: { ··· 56 56 mountedContainers.length = 0 57 57 }) 58 58 59 + // VueUiXy is imported directly in <script setup>, so global stubs cannot override it. 60 + // We mock the module itself to prevent vue-data-ui from mounting charts during tests 61 + // (it relies on DOM measurements and causes runtime errors in Vitest / Playwright). 62 + // This render-function stub avoids the Vue runtime-compiler warning and keeps slots working. 63 + vi.mock('vue-data-ui/vue-ui-xy', () => { 64 + return { 65 + VueUiXy: defineComponent({ 66 + name: 'VueUiXy', 67 + inheritAttrs: false, 68 + setup(_, { attrs, slots }) { 69 + return () => 70 + h('div', { ...attrs, 'data-test-id': 'vue-ui-xy-stub' }, slots.default?.() ?? []) 71 + }, 72 + }), 73 + } 74 + }) 75 + 59 76 // Import components from #components where possible 60 77 // For server/client variants, we need to import directly to test the specific variant 61 78 import { ··· 75 92 CompareFacetCard, 76 93 CompareFacetRow, 77 94 CompareFacetSelector, 95 + CompareLineChart, 78 96 ComparePackageSelector, 79 97 DateTime, 80 98 DependencyPathPopup, ··· 95 113 PackageCompatibility, 96 114 PackageDependencies, 97 115 PackageDeprecatedTree, 98 - PackageDownloadAnalytics, 99 116 PackageInstallScripts, 100 117 PackageKeywords, 101 118 PackageList, ··· 138 155 // The #components import automatically provides the client variant 139 156 import HeaderAccountMenuServer from '~/components/Header/AccountMenu.server.vue' 140 157 import ToggleServer from '~/components/Settings/Toggle.server.vue' 158 + import PackageDownloadAnalytics from '~/components/Package/DownloadAnalytics.vue' 141 159 142 160 describe('component accessibility audits', () => { 143 161 describe('DateTime', () => { ··· 534 552 ] 535 553 536 554 it('should have no accessibility violations (non-modal)', async () => { 537 - // Test only non-modal mode to avoid vue-data-ui chart rendering issues 538 - const component = await mountSuspended(PackageDownloadAnalytics, { 555 + const wrapper = await mountSuspended(PackageDownloadAnalytics, { 539 556 props: { 540 557 weeklyDownloads: mockWeeklyDownloads, 541 558 packageName: 'vue', ··· 543 560 inModal: false, 544 561 }, 545 562 }) 546 - const results = await runAxe(component) 563 + 564 + const results = await runAxe(wrapper) 547 565 expect(results.violations).toEqual([]) 548 566 }) 549 567 550 568 it('should have no accessibility violations with empty data', async () => { 551 - const component = await mountSuspended(PackageDownloadAnalytics, { 569 + const wrapper = await mountSuspended(PackageDownloadAnalytics, { 552 570 props: { 553 571 weeklyDownloads: [], 554 572 packageName: 'vue', ··· 556 574 inModal: false, 557 575 }, 558 576 }) 559 - const results = await runAxe(component) 577 + 578 + const results = await runAxe(wrapper) 560 579 expect(results.violations).toEqual([]) 561 580 }) 562 - 563 - // Note: Modal mode tests with inModal: true are skipped because vue-data-ui VueUiXy 564 - // component has issues in the test environment (requires DOM measurements). 565 581 }) 566 582 567 583 describe('PackagePlaygrounds', () => { ··· 1459 1475 describe('CompareFacetSelector', () => { 1460 1476 it('should have no accessibility violations', async () => { 1461 1477 const component = await mountSuspended(CompareFacetSelector) 1478 + const results = await runAxe(component) 1479 + expect(results.violations).toEqual([]) 1480 + }) 1481 + }) 1482 + 1483 + describe('CompareLineChart', () => { 1484 + it('should have no accessibility violations with no packages', async () => { 1485 + const component = await mountSuspended(CompareLineChart, { 1486 + props: { packages: [] }, 1487 + global: { 1488 + stubs: { 1489 + DownloadAnalytics: { 1490 + template: '<div data-test-id="download-analytics-stub"></div>', 1491 + }, 1492 + }, 1493 + }, 1494 + }) 1495 + const results = await runAxe(component) 1496 + expect(results.violations).toEqual([]) 1497 + }) 1498 + 1499 + it('should have no accessibility violations with packages selected', async () => { 1500 + const component = await mountSuspended(CompareLineChart, { 1501 + props: { packages: ['vue', 'react'] }, 1502 + global: { 1503 + stubs: { 1504 + DownloadAnalytics: { 1505 + template: '<div data-test-id="download-analytics-stub"></div>', 1506 + }, 1507 + }, 1508 + }, 1509 + }) 1462 1510 const results = await runAxe(component) 1463 1511 expect(results.violations).toEqual([]) 1464 1512 })
+27
test/unit/app/utils/frameworks.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + SHOWCASED_FRAMEWORKS, 4 + getFrameworkColor, 5 + isListedFramework, 6 + type ShowcasedFramework, 7 + } from '../../../../app/utils/frameworks' 8 + 9 + describe('getFrameworkColor', () => { 10 + it('returns the color a listed framework', () => { 11 + SHOWCASED_FRAMEWORKS.forEach((framework: ShowcasedFramework) => { 12 + expect(getFrameworkColor(framework.package)).toBe(framework.color) 13 + }) 14 + }) 15 + }) 16 + 17 + describe('isListedFramework', () => { 18 + it('returns true for a listed framework', () => { 19 + SHOWCASED_FRAMEWORKS.forEach((framework: ShowcasedFramework) => { 20 + expect(isListedFramework(framework.package)).toBe(true) 21 + }) 22 + }) 23 + 24 + it('returns false for non listed frameworks', () => { 25 + expect(isListedFramework('leftpad')).toBe(false) 26 + }) 27 + })