From 56c60244c90eae3f17ef29fad33aa4040132d11e Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 8 May 2026 00:11:07 +0200 Subject: [PATCH 1/6] chore: bump vue-data-ui from 3.19.3 to 3.19.4 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5d4e8c9427..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.19.3", + "vue-data-ui": "3.19.4", "vue-router": "5.0.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ce4e5649..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.19.3 - version: 3.19.3(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) @@ -11488,8 +11488,8 @@ packages: vue-component-type-helpers@3.2.8: resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} - vue-data-ui@3.19.3: - resolution: {integrity: sha512-cBBD6NnZnXMQ1ZDKNxhjPqxq7bxwzZL+WnNxM8O2lJ74384TzfbdMKiKk94QH6jy7B4odCl0MCt4VRgp1LDYCA==} + vue-data-ui@3.19.4: + resolution: {integrity: sha512-kp12dZnHWCwCczscGbmwec9rjtCFqYFDO5Abenpn2mKaZUUNWrMwnoCVwYtdu8LeBBhs/JQLX7Ty6OjlK05kag==} peerDependencies: jspdf: '>=3.0.1' vue: '>=3.3.0' @@ -24333,7 +24333,7 @@ snapshots: vue-component-type-helpers@3.2.8: {} - vue-data-ui@3.19.3(vue@3.5.34): + vue-data-ui@3.19.4(vue@3.5.34): dependencies: vue: 3.5.34(typescript@6.0.2) From dd56b1308895fbc31b506abd4ab0d3aff9303fa9 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 8 May 2026 00:11:45 +0200 Subject: [PATCH 2/6] feat: add composable --- app/composables/useChartTooltipPosition.ts | 26 ++++ .../use-chart-tooltip-position.spec.ts | 113 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 app/composables/useChartTooltipPosition.ts create mode 100644 test/unit/app/composables/use-chart-tooltip-position.spec.ts 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/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) + }) +}) From 4a67d005db87b229421859c459d134246566c778 Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 8 May 2026 00:12:15 +0200 Subject: [PATCH 3/6] feat: position tooltips to the side to free the view --- app/components/Package/TimelineChart.vue | 5 +++++ app/components/Package/TrendsChart.vue | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index ac2b9de80d..53b0be9727 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -18,6 +18,7 @@ import { } from '~/utils/charts' import type { TimelineVersion, SubEvent } from '~~/server/api/registry/timeline/[...pkg].get' import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' +import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' import('vue-data-ui/style.css') @@ -250,6 +251,8 @@ function buildExportFilename(extension: 'png' | 'csv' | 'svg') { return `${sanitise(packageName.value)}_${$t('package.links.timeline')}_${metricLabel.value.toLocaleLowerCase().replaceAll(' ', '-')}.${extension}` } +const tooltipPosition = useChartTooltipPosition(chartRef) + const config = computed(() => { return { theme: isDarkMode.value ? 'dark' : '', @@ -316,6 +319,8 @@ const config = computed(() => { color: colors.value.fg, }, tooltip: { + position: tooltipPosition.value, + offsetX: 24, borderColor: colors.value.border, borderRadius: 6, backgroundColor: colors.value.bg, diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index bf57c7c760..f9ca4fb701 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') @@ -67,6 +68,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) const isZoomed = shallowRef(false) +const chartRef = useTemplateRef('chartRef') + function setIsZoom({ isZoom }: { isZoom: boolean }) { isZoomed.value = isZoom } @@ -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" > Date: Thu, 7 May 2026 22:24:28 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- test/unit/app/composables/use-chart-tooltip-position.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/app/composables/use-chart-tooltip-position.spec.ts b/test/unit/app/composables/use-chart-tooltip-position.spec.ts index 6e10de57c5..ca2f7af822 100644 --- a/test/unit/app/composables/use-chart-tooltip-position.spec.ts +++ b/test/unit/app/composables/use-chart-tooltip-position.spec.ts @@ -1,4 +1,4 @@ -import type { computed} from 'vue'; +import type { computed } from 'vue' import { ref, shallowRef } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' From 5fb29b77901322acc534390ed31372dfb8e63a7d Mon Sep 17 00:00:00 2001 From: graphieros Date: Fri, 8 May 2026 00:25:44 +0200 Subject: [PATCH 6/6] fix: empty class --- test/unit/app/composables/use-chart-tooltip-position.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/unit/app/composables/use-chart-tooltip-position.spec.ts b/test/unit/app/composables/use-chart-tooltip-position.spec.ts index ca2f7af822..70429da797 100644 --- a/test/unit/app/composables/use-chart-tooltip-position.spec.ts +++ b/test/unit/app/composables/use-chart-tooltip-position.spec.ts @@ -11,7 +11,9 @@ const elementX = ref(0) const elementWidth = ref(0) const isOutside = ref(true) -class MockHTMLElement {} +const MockHTMLElement = class { + public readonly nodeType = 1 +} vi.stubGlobal('HTMLElement', MockHTMLElement)