diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue new file mode 100644 index 0000000000..8669383dd4 --- /dev/null +++ b/app/components/Package/TimelineChart.vue @@ -0,0 +1,924 @@ + + + + + diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index bf57c7c760..083e14e003 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -25,6 +25,7 @@ import { } from '~/utils/chart-data-prediction' import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies' import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from '~/utils/charts' +import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' import('vue-data-ui/style.css') @@ -71,6 +72,8 @@ function setIsZoom({ isZoom }: { isZoom: boolean }) { isZoomed.value = isZoom } +const chartRef = useTemplateRef('chartRef') + const { width } = useElementSize(rootEl) const compactNumberFormatter = useCompactNumberFormatter() @@ -1385,6 +1388,8 @@ watch( { immediate: true }, ) +const tooltipPosition = useChartTooltipPosition(chartRef) + // VueUiXy chart component configuration const chartConfig = computed(() => { return { @@ -1518,6 +1523,9 @@ const chartConfig = computed(() => { legend: { show: false, position: 'top' }, tooltip: { teleportTo: props.inModal ? '#chart-modal' : undefined, + position: tooltipPosition.value, + offsetX: 24, + offsetY: isMultiPackageMode.value ? undefined : -24, borderColor: 'transparent', backdropFilter: false, backgroundColor: 'transparent', @@ -1930,6 +1938,7 @@ const isSparklineLayout = computed({ :aria-labelledby="isMultiPackageMode ? 'combined-chart-layout-tab' : undefined" > +
diff --git a/app/components/Tab/Item.vue b/app/components/Tab/Item.vue index 6b0f4c3aff..4337c061ce 100644 --- a/app/components/Tab/Item.vue +++ b/app/components/Tab/Item.vue @@ -8,10 +8,12 @@ const props = withDefaults( value: string icon?: IconClass tabId?: string + controlsPanel?: boolean variant?: 'primary' | 'secondary' size?: 'sm' | 'md' }>(), { + controlsPanel: true, variant: 'secondary', size: 'md', }, @@ -22,12 +24,13 @@ const attrs = useAttrs() const selected = inject>('tabs-selected') const getTabId = inject<(value: string) => string>('tabs-tab-id') const getPanelId = inject<(value: string) => string>('tabs-panel-id') + if (!selected || !getTabId || !getPanelId) { throw new Error('TabItem must be used inside a TabRoot component') } const isSelected = computed(() => selected.value === props.value) const resolvedTabId = computed(() => props.tabId ?? getTabId(props.value)) -const resolvedPanelId = computed(() => getPanelId(props.value)) +const resolvedPanelId = computed(() => (props.controlsPanel ? getPanelId(props.value) : undefined)) const select = () => { selected.value = props.value } diff --git a/app/composables/useChartTooltipPosition.ts b/app/composables/useChartTooltipPosition.ts new file mode 100644 index 0000000000..0df3945c64 --- /dev/null +++ b/app/composables/useChartTooltipPosition.ts @@ -0,0 +1,26 @@ +/** + * This composable returns a dynamic position to be fed to vue-data-ui components configugration for the `tooltip.position` attribute. Use it to position tooltips to the right or left side to free the view for datapoints, typically on line charts. + */ + +import { useMouseInElement } from '@vueuse/core' + +type TooltipPosition = 'left' | 'right' | 'center' +type TemplateRefValue = HTMLElement | { $el?: HTMLElement } | null | undefined + +export function useChartTooltipPosition( + chartRef: MaybeRefOrGetter, +): ComputedRef { + const target = computed(() => { + const value = toValue(chartRef) + if (!value) return null + if (value instanceof HTMLElement) return value + return value.$el || null + }) + + const { elementX, elementWidth, isOutside } = useMouseInElement(target) + + return computed(() => { + if (isOutside.value || elementWidth.value === 0) return 'center' + return elementX.value > elementWidth.value / 2 ? 'left' : 'right' + }) +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 1fc879a0a4..c5d5734ff6 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -50,6 +50,10 @@ export interface AppSettings { anomaliesFixed: boolean predictionPoints: number } + timelineChart: { + isZeroBased: boolean + showZoom: boolean + } } const DEFAULT_SETTINGS: AppSettings = { @@ -77,6 +81,10 @@ const DEFAULT_SETTINGS: AppSettings = { anomaliesFixed: true, predictionPoints: 4, }, + timelineChart: { + isZeroBased: false, + showZoom: false, + }, } const STORAGE_KEY = 'npmx-settings' diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index bb9f577eb0..a007efde15 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -4,6 +4,7 @@ import { compare } from 'semver' import type { TimelineResponse, TimelineVersion, + SubEvent, } from '~~/server/api/registry/timeline/[...pkg].get' import type { TimelineSizeResponse } from '~~/server/api/registry/timeline/sizes/[...pkg].get' @@ -111,14 +112,17 @@ function sizeKey(ver: string) { } async function fetchSizes(offset: number) { + const requestedPackage = packageName.value sizeFetchesInFlight.value++ try { const data = await $fetch( - `/api/registry/timeline/sizes/${packageName.value}`, + `/api/registry/timeline/sizes/${requestedPackage}`, { query: { offset, limit: PAGE_SIZE } }, ) + if (requestedPackage !== packageName.value) return + for (const entry of data.sizes) { - sizeCache.set(sizeKey(entry.version), { + sizeCache.set(`${requestedPackage}@${entry.version}`, { totalSize: entry.totalSize, dependencyCount: entry.dependencyCount, }) @@ -143,13 +147,6 @@ if (import.meta.client) { const bytesFormatter = useBytesFormatter() -interface SubEvent { - key: string - positive: boolean - icon: string - text: string -} - // Detect notable changes between consecutive versions (size, license, ESM, types) // Versions are compared against their semver predecessor, not chronological neighbor, // so interleaved legacy releases don't produce misleading cross-line diffs. @@ -308,6 +305,8 @@ const versionSubEvents = computed(() => { return result }) +const selectedVersion = shallowRef(null) + useSeoMeta({ title: () => `Timeline - ${packageName.value} - npmx`, description: () => `Version timeline for ${packageName.value}`, @@ -325,12 +324,21 @@ useSeoMeta({ page="timeline" /> -
- -
-
+
+
+
+ +
+
+
  1. @@ -346,6 +354,10 @@ useSeoMeta({ class="text-sm font-medium" :class="entry.version === version ? 'text-accent' : ''" dir="ltr" + @mouseenter="selectedVersion = entry.version" + @mouseleave="selectedVersion = null" + @focus="selectedVersion = entry.version" + @blur="selectedVersion = null" > {{ entry.version }} @@ -427,18 +439,3 @@ useSeoMeta({
- - diff --git a/app/utils/charts.ts b/app/utils/charts.ts index 3cdbe3fd34..7c5a86ff3b 100644 --- a/app/utils/charts.ts +++ b/app/utils/charts.ts @@ -9,6 +9,7 @@ import type { VueUiXyDatasetLineItem, } from 'vue-data-ui' import type { ChartTimeGranularity } from '~/types/chart' +import type { SubEvent } from '~~/server/api/registry/timeline/[...pkg].get' export function sum(numbers: number[]): number { return numbers.reduce((a, b) => a + b, 0) @@ -451,6 +452,37 @@ export type FacetBarChartConfig = VueUiHorizontalBarConfig & { $t: TrendTranslateFunction } +export type TimelineSizeCacheValue = { + totalSize: number + dependencyCount: number +} + +export type ConvertedTimelineSizeCacheEntry = TimelineSizeCacheValue & { + name: string +} + +export type EnrichedTimelineSizeCacheEntry = ConvertedTimelineSizeCacheEntry & { + version: string + time?: string + license?: string + type?: string + hasTypes?: boolean + hasTrustedPublisher?: boolean + hasProvenance?: boolean + tags: string[] + events: SubEvent[] + hasPositive: boolean + hasNegative: boolean +} + +export type TimelineChartConfig = VueUiXyConfig & { + metric: 'totalSize' | 'dependencyCount' + packageName: string + copy: (text: string) => Promise + $t: TrendTranslateFunction + numberFormatter: (value: number) => string +} + // Used for TrendsChart.vue export function createAltTextForTrendLineChart({ dataset, @@ -705,6 +737,68 @@ export async function copyAltTextForCompareScatterChart({ await config.copy(altText) } +// Used for TimelineChart.vue +export function createAltTextForTimelineChart({ + dataset, + config, +}: AltCopyArgs) { + if (!dataset) return '' + const metric = + config.metric === 'totalSize' + ? config.$t('package.stats.install_size') + : config.$t('compare.dependencies') + const withEvents = dataset.filter(d => d.events.length) + const first = dataset[0] + const last = dataset.at(-1) + + if (!first || !last) return '' + + const firstValue = config.metric === 'totalSize' ? first?.totalSize : first?.dependencyCount + const lastValue = config.metric === 'totalSize' ? last?.totalSize : last?.dependencyCount + const baseline = firstValue ?? 0 + const current = lastValue ?? baseline + const overall_progress_percentage = + baseline > 0 ? Math.round(((current - baseline) / baseline) * 100) : 0 + + const version_events = withEvents + .map(item => + config.$t('package.timeline.chart.copy_alt.version_events', { + version: item.version, + // eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys + events: item.events.map(e => config.$t(e.text).toLocaleLowerCase()).join(', '), + }), + ) + .join('; ') + + const key_changes = !withEvents.length + ? '' + : config.$t('package.timeline.chart.copy_alt.key_changes', { + version_events, + }) + + const altText = config.$t('package.timeline.chart.copy_alt.general_description', { + metric: metric.toLocaleLowerCase(), + package: config.packageName, + first: first?.version ?? '', + last: last?.version ?? '', + first_value: config.numberFormatter(firstValue ?? 0), + last_value: config.numberFormatter(lastValue ?? 0), + overall_progress_percentage, + key_changes, + watermark: config.$t('package.trends.copy_alt.watermark'), + }) + + return altText +} + +export async function copyAltTextForTimelineChart({ + dataset, + config, +}: AltCopyArgs) { + const altText = createAltTextForTimelineChart({ dataset, config }) + await config.copy(altText) +} + // Used in chart context menu callbacks // @todo replace with downloadFileLink export function loadFile(link: string, filename: string) { diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9144df697f..5cf61d60a3 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -591,7 +591,18 @@ "trusted_publisher_added": "Trusted publishing enabled", "trusted_publisher_removed": "Trusted publishing removed", "provenance_added": "Provenance enabled", - "provenance_removed": "Provenance removed" + "provenance_removed": "Provenance removed", + "chart": { + "tab_aria_label": "Metric selection", + "base_scale": "start y-axis at zero", + "zoom": "zoom", + "reset_minimap": "reset minimap", + "copy_alt": { + "key_changes": "Key changes: {version_events}.", + "version_events": "version {version}: {events}", + "general_description": "Line chart showing the {metric} of the {package} package, from version {first} to {last}. The {metric} in version {first} is {first_value}, in version {last} is {last_value} ({overall_progress_percentage}% overall). {key_changes} {watermark}." + } + } }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 7c21cc4f60..ef6b16f298 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -579,7 +579,18 @@ "trusted_publisher_added": "Vérification ajoutée", "trusted_publisher_removed": "Vérification enlevée", "provenance_added": "Preuve de provenance ajoutée", - "provenance_removed": "Preuve de provenance enlevée" + "provenance_removed": "Preuve de provenance enlevée", + "chart": { + "tab_aria_label": "Sélection de métrique", + "base_scale": "positionner les ordonnées à zéro", + "zoom": "zoom", + "reset_minimap": "Réinitialiser la mini-carte", + "copy_alt": { + "key_changes": "Principaux changements: {version_events}", + "version_events": "version {version}: {events}", + "general_description": "Graphique en ligne montrant la métrique {metric} pour le paquet {package}, depuis la version {first} à {last}. La valeur de la métrique {metric} pour la version {first} est {first_value}, et {last_value} pour la version {last} ({overall_progress_percentage}% dans l'ensemble). {key_changes} {watermark}." + } + } }, "dependencies": { "title": "Dépendances ({count})", diff --git a/i18n/schema.json b/i18n/schema.json index b83d9682c4..31b662ee58 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1779,6 +1779,39 @@ }, "provenance_removed": { "type": "string" + }, + "chart": { + "type": "object", + "properties": { + "tab_aria_label": { + "type": "string" + }, + "base_scale": { + "type": "string" + }, + "zoom": { + "type": "string" + }, + "reset_minimap": { + "type": "string" + }, + "copy_alt": { + "type": "object", + "properties": { + "key_changes": { + "type": "string" + }, + "version_events": { + "type": "string" + }, + "general_description": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/package.json b/package.json index abb4fe1a0b..a03bfcef60 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.1.16", "vue": "3.5.34", - "vue-data-ui": "3.18.2", + "vue-data-ui": "3.19.4", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fff23140e..3025ec45fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,8 +254,8 @@ importers: specifier: 3.5.34 version: 3.5.34(typescript@6.0.2) vue-data-ui: - specifier: 3.18.2 - version: 3.18.2(vue@3.5.34) + specifier: 3.19.4 + version: 3.19.4(vue@3.5.34) vue-router: specifier: 5.0.4 version: 5.0.4(@vue/compiler-sfc@3.5.34)(vue@3.5.34) @@ -4009,61 +4009,31 @@ packages: cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-arm64@0.22.0': - resolution: {integrity: sha512-/exgXceakHbQrzaHTtKOe7MuDATaWMCCWpsCDQCZKeYhLGXzComipTrCYnHzAXrdnNBb5r5K+RRf5A6ormrhMA==} - cpu: [arm64] - os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.20.0': resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.22.0': - resolution: {integrity: sha512-xFGdIahlmUbK+/MpZ5y08D0ewMGLDbd2Vki5wxVFYg50lSrtgPAtdDl+kqKZLNaFu0zpMar8n9wv1le05sL/jw==} - cpu: [x64] - os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.20.0': resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-arm64@0.22.0': - resolution: {integrity: sha512-53RvC9f77eUo+V1dfQNwGVnsIfPJFMibRR0ee128EUpYNDOZe/ojmCfuXJeU7cY91V7r7fZSm42KPJocXUX8og==} - cpu: [arm64] - os: [linux] - '@oxlint-tsgolint/linux-x64@0.20.0': resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.22.0': - resolution: {integrity: sha512-evZcJAZ9hjNyuN69RnXwbt+U2pAOcYt+yvqukgugiCkRm4iBZ0R0CvpY1tgfG2XcGUhEPh8dljO+nPZTEVGpCQ==} - cpu: [x64] - os: [linux] - '@oxlint-tsgolint/win32-arm64@0.20.0': resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-arm64@0.22.0': - resolution: {integrity: sha512-7jTO+k1mr5BxRAI2fxc1NRcE3MAbHNZ0Vef9SD1yAR6d1E6qEv5D/D7yuHpQpw6AO3qoecSVo2Jzr+JirN61+w==} - cpu: [arm64] - os: [win32] - '@oxlint-tsgolint/win32-x64@0.20.0': resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==} cpu: [x64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.22.0': - resolution: {integrity: sha512-7lbl9XFcqO+scsynxMzTQdl0XUe6sBUCyY/oGWvCB+JmV4U+70vzSyZJdTEzzxtkZiNnUVFFh9RJLmoiQSne+w==} - cpu: [x64] - os: [win32] - '@oxlint/binding-android-arm-eabi@1.58.0': resolution: {integrity: sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9442,10 +9412,6 @@ packages: resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==} hasBin: true - oxlint-tsgolint@0.22.0: - resolution: {integrity: sha512-ku4MecLmCQIj1ScCtzNAqTuyl0BJQ02B36fJT+c5XQihHpYSFak+FC3GYO5fPyYk4oDwi0w0S7hTvrpNzuZhig==} - hasBin: true - oxlint@1.58.0: resolution: {integrity: sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -11522,8 +11488,8 @@ packages: vue-component-type-helpers@3.2.8: resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} - vue-data-ui@3.18.2: - resolution: {integrity: sha512-BJP+YMrJeAdVnT2rmBsZBe+rHksReCHrzFM8MYXAgndgAdPJlzsLigylwflLhm9sndQeAt6ihCslX0VIU+nyUQ==} + vue-data-ui@3.19.4: + resolution: {integrity: sha512-kp12dZnHWCwCczscGbmwec9rjtCFqYFDO5Abenpn2mKaZUUNWrMwnoCVwYtdu8LeBBhs/JQLX7Ty6OjlK05kag==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -15579,39 +15545,21 @@ snapshots: '@oxlint-tsgolint/darwin-arm64@0.20.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.22.0': - optional: true - '@oxlint-tsgolint/darwin-x64@0.20.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.22.0': - optional: true - '@oxlint-tsgolint/linux-arm64@0.20.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.22.0': - optional: true - '@oxlint-tsgolint/linux-x64@0.20.0': optional: true - '@oxlint-tsgolint/linux-x64@0.22.0': - optional: true - '@oxlint-tsgolint/win32-arm64@0.20.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.22.0': - optional: true - '@oxlint-tsgolint/win32-x64@0.20.0': optional: true - '@oxlint-tsgolint/win32-x64@0.22.0': - optional: true - '@oxlint/binding-android-arm-eabi@1.58.0': optional: true @@ -21924,16 +21872,6 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.20.0 '@oxlint-tsgolint/win32-x64': 0.20.0 - oxlint-tsgolint@0.22.0: - optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.22.0 - '@oxlint-tsgolint/darwin-x64': 0.22.0 - '@oxlint-tsgolint/linux-arm64': 0.22.0 - '@oxlint-tsgolint/linux-x64': 0.22.0 - '@oxlint-tsgolint/win32-arm64': 0.22.0 - '@oxlint-tsgolint/win32-x64': 0.22.0 - optional: true - oxlint@1.58.0(oxlint-tsgolint@0.20.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.58.0 @@ -21981,30 +21919,6 @@ snapshots: oxlint-tsgolint: 0.20.0 optional: true - oxlint@1.61.0(oxlint-tsgolint@0.22.0): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.61.0 - '@oxlint/binding-android-arm64': 1.61.0 - '@oxlint/binding-darwin-arm64': 1.61.0 - '@oxlint/binding-darwin-x64': 1.61.0 - '@oxlint/binding-freebsd-x64': 1.61.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.61.0 - '@oxlint/binding-linux-arm-musleabihf': 1.61.0 - '@oxlint/binding-linux-arm64-gnu': 1.61.0 - '@oxlint/binding-linux-arm64-musl': 1.61.0 - '@oxlint/binding-linux-ppc64-gnu': 1.61.0 - '@oxlint/binding-linux-riscv64-gnu': 1.61.0 - '@oxlint/binding-linux-riscv64-musl': 1.61.0 - '@oxlint/binding-linux-s390x-gnu': 1.61.0 - '@oxlint/binding-linux-x64-gnu': 1.61.0 - '@oxlint/binding-linux-x64-musl': 1.61.0 - '@oxlint/binding-openharmony-arm64': 1.61.0 - '@oxlint/binding-win32-arm64-msvc': 1.61.0 - '@oxlint/binding-win32-ia32-msvc': 1.61.0 - '@oxlint/binding-win32-x64-msvc': 1.61.0 - oxlint-tsgolint: 0.22.0 - optional: true - p-all@5.0.1: dependencies: p-map: 6.0.0 @@ -24263,7 +24177,7 @@ snapshots: optionalDependencies: eslint: 9.39.2(jiti@2.6.1) optionator: 0.9.4 - oxlint: 1.61.0(oxlint-tsgolint@0.22.0) + oxlint: 1.61.0(oxlint-tsgolint@0.20.0) typescript: 6.0.2 vue-tsc: 3.2.6(typescript@6.0.2) @@ -24419,7 +24333,7 @@ snapshots: vue-component-type-helpers@3.2.8: {} - vue-data-ui@3.18.2(vue@3.5.34): + vue-data-ui@3.19.4(vue@3.5.34): dependencies: vue: 3.5.34(typescript@6.0.2) diff --git a/server/api/registry/timeline/[...pkg].get.ts b/server/api/registry/timeline/[...pkg].get.ts index 05ffbecdf6..aa4455513f 100644 --- a/server/api/registry/timeline/[...pkg].get.ts +++ b/server/api/registry/timeline/[...pkg].get.ts @@ -18,6 +18,13 @@ export interface TimelineResponse { total: number } +export interface SubEvent { + key: string + positive: boolean + icon: string + text: string +} + /** * Returns paginated version timeline data for a package. * diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index c4381fd7b6..2fae464f53 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -269,6 +269,7 @@ import SearchProviderToggleServer from '~/components/SearchProviderToggle.server import PackageTrendsChart from '~/components/Package/TrendsChart.vue' import FacetBarChart from '~/components/Compare/FacetBarChart.vue' import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' +import PackageTimelineChart from '~/components/Package/TimelineChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import SizeDecrease from '~/components/Package/SizeDecrease.vue' @@ -1066,6 +1067,22 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) + describe('PackageTimelineChart', () => { + it('should have no accessibility violations', async () => { + const wrapper = await mountSuspended(PackageTimelineChart, { + props: { + sizeCache: new Map(), + versionSubEvents: new Map(), + timelineEntries: [], + selectedVersion: null, + loading: false, + }, + }) + const results = await runAxe(wrapper) + expect(results.violations).toEqual([]) + }) + }) + describe('FacetBarChart', () => { it('should have no accessibility violations', async () => { const wrapper = await mountSuspended(FacetBarChart, { diff --git a/test/unit/app/composables/use-chart-tooltip-position.spec.ts b/test/unit/app/composables/use-chart-tooltip-position.spec.ts new file mode 100644 index 0000000000..591fcd906e --- /dev/null +++ b/test/unit/app/composables/use-chart-tooltip-position.spec.ts @@ -0,0 +1,113 @@ +import { computed, ref, shallowRef, toValue } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' + +const mouseState = vi.hoisted(() => ({ + target: null as unknown, +})) + +const elementX = ref(0) +const elementWidth = ref(0) +const isOutside = ref(true) + +class MockHTMLElement {} + +vi.stubGlobal('HTMLElement', MockHTMLElement) +vi.stubGlobal('computed', computed) +vi.stubGlobal('toValue', toValue) + +vi.mock('@vueuse/core', () => ({ + useMouseInElement: vi.fn(target => { + mouseState.target = target + return { + elementX, + elementWidth, + isOutside, + } + }), +})) + +describe('useChartTooltipPosition', () => { + beforeEach(() => { + elementX.value = 0 + elementWidth.value = 0 + isOutside.value = true + mouseState.target = null + }) + + it('returns center when the mouse is outside', () => { + const element = new MockHTMLElement() as HTMLElement + const position = useChartTooltipPosition(shallowRef(element)) + isOutside.value = true + elementWidth.value = 100 + elementX.value = 75 + expect(position.value).toBe('center') + }) + + it('returns center when element width is 0', () => { + const element = new MockHTMLElement() as HTMLElement + const position = useChartTooltipPosition(shallowRef(element)) + isOutside.value = false + elementWidth.value = 0 + elementX.value = 75 + expect(position.value).toBe('center') + }) + + it('returns left when the mouse is on the right half of the element', () => { + const element = new MockHTMLElement() as HTMLElement + const position = useChartTooltipPosition(shallowRef(element)) + isOutside.value = false + elementWidth.value = 100 + elementX.value = 51 + expect(position.value).toBe('left') + }) + + it('returns right when the mouse is on the left half of the element', () => { + const element = new MockHTMLElement() as HTMLElement + const position = useChartTooltipPosition(shallowRef(element)) + isOutside.value = false + elementWidth.value = 100 + elementX.value = 49 + expect(position.value).toBe('right') + }) + + it('returns right when the mouse is exactly at the center', () => { + const element = new MockHTMLElement() as HTMLElement + const position = useChartTooltipPosition(shallowRef(element)) + isOutside.value = false + elementWidth.value = 100 + elementX.value = 50 + expect(position.value).toBe('right') + }) + + it('accepts a Vue component ref exposing $el', () => { + const element = new MockHTMLElement() as HTMLElement + const componentReference = shallowRef({ $el: element }) + useChartTooltipPosition(componentReference) + expect((mouseState.target as ReturnType).value).toBe(element) + }) + + it('returns null as target when ref value is null', () => { + useChartTooltipPosition(shallowRef(null)) + expect((mouseState.target as ReturnType).value).toBe(null) + }) + + it('returns null when component ref has no $el', () => { + const componentReference = shallowRef({}) + useChartTooltipPosition(componentReference) + expect((mouseState.target as ReturnType).value).toBe(null) + }) + + it('uses the HTMLElement directly as target', () => { + const element = new MockHTMLElement() as HTMLElement + useChartTooltipPosition(shallowRef(element)) + expect((mouseState.target as ReturnType).value).toBe(element) + }) + + it('uses the component $el as target', () => { + const element = new MockHTMLElement() as HTMLElement + const componentReference = shallowRef({ $el: element }) + useChartTooltipPosition(componentReference) + expect((mouseState.target as ReturnType).value).toBe(element) + }) +}) diff --git a/test/unit/app/utils/charts.spec.ts b/test/unit/app/utils/charts.spec.ts index 72c3d6c998..ae2ff47593 100644 --- a/test/unit/app/utils/charts.spec.ts +++ b/test/unit/app/utils/charts.spec.ts @@ -11,6 +11,8 @@ import { copyAltTextForTrendLineChart, createAltTextForVersionsBarChart, copyAltTextForVersionsBarChart, + createAltTextForTimelineChart, + copyAltTextForTimelineChart, loadFile, sanitise, insertLineBreaks, @@ -19,6 +21,8 @@ import { type TrendLineDataset, type VersionsBarConfig, type VersionsBarDataset, + type TimelineChartConfig, + type EnrichedTimelineSizeCacheEntry, } from '~/utils/charts' import type { AltCopyArgs } from 'vue-data-ui' @@ -35,6 +39,19 @@ function createTranslateMock() { return { translate, calls } } +function createTimelineConfig(overrides: Partial = {}): TimelineChartConfig { + const { translate } = createTranslateMock() + const config: TimelineChartConfig = { + numberFormatter: (value: number) => `nf${value}`, + packageName: 'nuxt', + metric: 'totalSize', + copy: vi.fn(async () => undefined), + $t: translate, + } as unknown as TimelineChartConfig + + return { ...config, ...overrides } +} + function createTrendLineConfig(overrides: Partial = {}): TrendLineConfig { const { translate } = createTranslateMock() @@ -1187,6 +1204,80 @@ describe('copyAltTextForVersionsBarChart', () => { }) }) +const timelineDataset = [ + { + dependencyCount: 100, + events: [], + version: '4.0.0', + totalSize: 120_000_000, + }, + { + dependencyCount: 80, + events: [], + version: '4.0.1', + totalSize: 115_000_000, + }, +] as unknown as EnrichedTimelineSizeCacheEntry[] + +describe('createAltTextForTimelineChart', () => { + it('handles empty dataset without throwing', () => { + const { translate } = createTranslateMock() + const config = createTimelineConfig({ $t: translate }) + + expect(() => + createAltTextForTimelineChart({ + dataset: [], + config, + } as AltCopyArgs), + ).not.toThrow() + }) + + it('returns empty string when dataset is null', () => { + const translateMock = createTranslateMock() + const config = createTimelineConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineChart({ + dataset: null, + config, + } as unknown as AltCopyArgs) + + expect(result).toBe('') + expect(translateMock.calls).toHaveLength(0) + }) + + it('returns an alt text', () => { + const translateMock = createTranslateMock() + const config = createTimelineConfig({ $t: translateMock.translate }) + + const result = createAltTextForTimelineChart({ + dataset: timelineDataset, + config, + } as unknown as AltCopyArgs) + + expect(result).toBe('t:package.timeline.chart.copy_alt.general_description') + expect(translateMock.calls).toHaveLength(3) + }) +}) + +describe('copyAltTextForTimelineChart', () => { + it('forwards createAltTextForTimelineChart result to config.copy', async () => { + const copyMock = vi.fn(async () => undefined) + const config = createTimelineConfig({ copy: copyMock }) + const expected = createAltTextForTimelineChart({ + dataset: timelineDataset, + config, + }) + + await copyAltTextForTimelineChart({ + dataset: timelineDataset, + config, + } as AltCopyArgs) + + expect(copyMock).toHaveBeenCalledTimes(1) + expect(copyMock).toHaveBeenCalledWith(expected) + }) +}) + describe('loadFile', () => { let createElementMock: ReturnType let clickMock: ReturnType