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" > =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) 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..70429da797 --- /dev/null +++ b/test/unit/app/composables/use-chart-tooltip-position.spec.ts @@ -0,0 +1,122 @@ +import type { computed } from 'vue' +import { ref, shallowRef } from 'vue' +import { afterEach, 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) + +const MockHTMLElement = class { + public readonly nodeType = 1 +} + +vi.stubGlobal('HTMLElement', MockHTMLElement) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +vi.mock('@vueuse/core', () => ({ + useMouseInElement: vi.fn(target => { + mouseState.target = target + + return { + elementX, + elementWidth, + isOutside, + } + }), +})) + +describe('useChartTooltipPosition', () => { + beforeEach(() => { + vi.stubGlobal('HTMLElement', MockHTMLElement) + 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) + }) +})