From 29a6655c7a78af5746fda4d677c6c19530327fef Mon Sep 17 00:00:00 2001 From: Michael Nesen Date: Tue, 14 Jan 2025 22:17:20 +0000 Subject: [PATCH] Add tooltips to Funnel Chart Next --- packages/polaris-viz/CHANGELOG.md | 6 +- .../src/components/FunnelChartNext/Chart.tsx | 84 ++++++++++- .../FunnelChartNext/FunnelChartNext.tsx | 7 +- .../components/Tooltip/Tooltip.scss | 33 +++++ .../components/Tooltip/Tooltip.tsx | 93 ++++++++++++ .../components/Tooltip/index.ts | 1 + .../components/Tooltip/tests/Tooltip.test.tsx | 140 ++++++++++++++++++ .../FunnelChartNext/components/index.ts | 1 + .../stories/Default.stories.tsx | 4 + .../FunnelChartNext.chromatic.stories.tsx | 4 + .../stories/Playground.stories.tsx | 4 + .../FunnelChartNext/tests/Chart.test.tsx | 32 ++++ 12 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.scss create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/tests/Tooltip.test.tsx diff --git a/packages/polaris-viz/CHANGELOG.md b/packages/polaris-viz/CHANGELOG.md index 2a4b9543e..0f886691d 100644 --- a/packages/polaris-viz/CHANGELOG.md +++ b/packages/polaris-viz/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - +## Unreleased + +### Added + +- Added Tooltips to `` ## [15.7.0] - 2025-01-08 diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx index 9bbf34b45..54004d4fe 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import {Fragment, useMemo} from 'react'; +import {Fragment, useMemo, useState} from 'react'; import {scaleBand, scaleLinear} from 'd3-scale'; import type {DataSeries, LabelFormatter} from '@shopify/polaris-viz-core'; import { @@ -16,7 +16,12 @@ import { import {FunnelChartSegment} from '../shared'; import {ChartElements} from '../ChartElements'; -import {FunnelChartLabels} from './components'; +import { + FunnelChartLabels, + FunnelTooltip, + Tooltip, + TooltipWithPortal, +} from './components'; import { LABELS_HEIGHT, LINE_GRADIENT, @@ -24,10 +29,15 @@ import { LINE_WIDTH, GAP, SEGMENT_WIDTH_RATIO, + TOOLTIP_HORIZONTAL_OFFSET, + TOOLTIP_HEIGHT, + TOOLTIP_WIDTH, } from './constants'; +import type {FunnelChartNextProps} from './FunnelChartNext'; export interface ChartProps { data: DataSeries[]; + tooltipLabels: FunnelChartNextProps['tooltipLabels']; seriesNameFormatter: LabelFormatter; labelFormatter: LabelFormatter; percentageFormatter?: (value: number) => string; @@ -36,6 +46,7 @@ export interface ChartProps { export function Chart({ data, + tooltipLabels, seriesNameFormatter, labelFormatter, percentageFormatter = (value: number) => { @@ -43,13 +54,19 @@ export function Chart({ }, renderScaleIconTooltipContent, }: ChartProps) { + const [tooltipIndex, setTooltipIndex] = useState(null); const {containerBounds} = useChartContext(); const dataSeries = data[0].data; const xValues = dataSeries.map(({key}) => key) as string[]; const yValues = dataSeries.map(({value}) => value) as [number, number]; const sanitizedYValues = yValues.map((value) => Math.max(0, value)); - const {width: drawableWidth, height: drawableHeight} = containerBounds ?? { + const { + width: drawableWidth, + height: drawableHeight, + x: chartX, + y: chartY, + } = containerBounds ?? { width: 0, height: 0, x: 0, @@ -107,9 +124,18 @@ export function Chart({ return labelFormatter(dataPoint.value); }); + const handleChartBlur = (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + const relatedTarget = event.relatedTarget as Node; + + if (!currentTarget.contains(relatedTarget)) { + setTooltipIndex(null); + } + }; + return ( - + setTooltipIndex(index)} + onMouseLeave={() => setTooltipIndex(null)} shouldApplyScaling={shouldApplyScaling} x={x} > @@ -182,7 +210,55 @@ export function Chart({ ); })} + {getTooltipMarkup()} ); + + function getTooltipMarkup() { + if (tooltipIndex == null) { + return null; + } + + const activeDataSeries = dataSeries[tooltipIndex]; + + if (activeDataSeries == null) { + return null; + } + + const xPosition = getXPosition(); + const yPosition = getYPosition(); + + return ( + + + + ); + + function getXPosition() { + if (tooltipIndex === 0) { + return chartX + barWidth + TOOLTIP_HORIZONTAL_OFFSET; + } + + const xOffset = (barWidth - TOOLTIP_WIDTH) / 2; + return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset; + } + + function getYPosition() { + const barHeight = getBarHeight(activeDataSeries.value ?? 0); + const yPosition = chartY + drawableHeight - barHeight; + + if (tooltipIndex === 0) { + return yPosition; + } + + return yPosition - TOOLTIP_HEIGHT; + } + } } diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx index e29c8689e..8d37ad657 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -12,6 +12,10 @@ import {ChartSkeleton} from '../'; import {Chart} from './Chart'; export type FunnelChartNextProps = { + tooltipLabels: { + reached: string; + dropped: string; + }; seriesNameFormatter?: LabelFormatter; labelFormatter?: LabelFormatter; renderScaleIconTooltipContent?: () => ReactNode; @@ -30,7 +34,7 @@ export function FunnelChartNext(props: FunnelChartNextProps) { isAnimated, state, errorText, - + tooltipLabels, seriesNameFormatter = DEFAULT_LABEL_FORMATTER, labelFormatter = DEFAULT_LABEL_FORMATTER, percentageFormatter, @@ -59,6 +63,7 @@ export function FunnelChartNext(props: FunnelChartNextProps) { ) : ( string; +} + +interface Data { + key: string; + value: string; + color: Color; + percent: number; +} + +export function Tooltip({ + activeIndex, + dataSeries, + tooltipLabels, + labelFormatter, + percentageFormatter, +}: TooltipContentProps) { + const point = dataSeries[activeIndex]; + const previousPoint = dataSeries[activeIndex - 1]; + const isFirst = activeIndex === 0; + + const dropOffPercentage = Math.abs( + calculateDropOff(previousPoint?.value ?? 0, point?.value ?? 0), + ); + + const data: Data[] = [ + { + key: tooltipLabels.reached, + value: labelFormatter(point.value), + color: FUNNEL_CHART_SEGMENT_FILL, + percent: isFirst ? 100 : 100 - dropOffPercentage, + }, + ]; + + if (!isFirst) { + data.push({ + key: tooltipLabels.dropped, + value: labelFormatter((previousPoint?.value ?? 0) - (point.value ?? 0)), + percent: dropOffPercentage, + color: FUNNEL_CHART_CONNECTOR_GRADIENT, + }); + } + + return ( + + {() => ( + + {point.key} +
+ {data.map(({key, value, color, percent}, index) => { + return ( +
+
+ + {key} +
+
+ {value} + + {percentageFormatter(percent)} + +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts new file mode 100644 index 000000000..f53bae244 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/index.ts @@ -0,0 +1 @@ +export {Tooltip} from './Tooltip'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/tests/Tooltip.test.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/tests/Tooltip.test.tsx new file mode 100644 index 000000000..277e52353 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/tests/Tooltip.test.tsx @@ -0,0 +1,140 @@ +import {mount} from '@shopify/react-testing'; + +import {Tooltip} from '../Tooltip'; +import {TooltipContentContainer} from '../../../../TooltipContent'; +import {SeriesIcon} from '../../../../shared/SeriesIcon'; +import {FUNNEL_CHART_SEGMENT_FILL} from '../../../../shared/FunnelChartSegment'; +import {FUNNEL_CHART_CONNECTOR_GRADIENT} from '../../../../shared/FunnelChartConnector'; + +const MOCK_DATA_SERIES = [ + {key: 'Step 1', value: 1000}, + {key: 'Step 2', value: 600}, + {key: 'Step 3', value: 300}, +]; + +const MOCK_TOOLTIP_LABELS = { + reached: 'Reached', + dropped: 'Dropped', +}; + +const mockLabelFormatter = (value: number) => `${value}`; +const mockPercentageFormatter = (value: number) => `${value}%`; + +describe('', () => { + it('renders a TooltipContentContainer', () => { + const tooltip = mount( + , + ); + + expect(tooltip).toContainReactComponent(TooltipContentContainer); + }); + + describe('first step', () => { + it('only shows reached information', () => { + const tooltip = mount( + , + ); + + expect(tooltip.findAll(SeriesIcon)).toHaveLength(1); + expect(tooltip).toContainReactText('Reached'); + expect(tooltip).not.toContainReactText('Dropped'); + }); + + it('shows expected value and percentage', () => { + const tooltip = mount( + , + ); + + expect(tooltip).toContainReactText('1000'); + expect(tooltip).toContainReactText('100%'); + }); + }); + + describe('subsequent steps', () => { + it('shows both reached and dropped information', () => { + const tooltip = mount( + , + ); + + expect(tooltip.findAll(SeriesIcon)).toHaveLength(2); + expect(tooltip).toContainReactText('Reached'); + expect(tooltip).toContainReactText('Dropped'); + }); + + it('shows correct values and percentages', () => { + const tooltip = mount( + , + ); + + expect(tooltip).toContainReactText('600'); + expect(tooltip).toContainReactText('60%'); + + expect(tooltip).toContainReactText('400'); + expect(tooltip).toContainReactText('40%'); + }); + + it('shows correct values and percentages for last step', () => { + const tooltip = mount( + , + ); + + expect(tooltip).toContainReactText('300'); + expect(tooltip).toContainReactText('50%'); + + expect(tooltip).toContainReactText('300'); + expect(tooltip).toContainReactText('50%'); + }); + }); + + it('uses correct colors for icons', () => { + const tooltip = mount( + , + ); + + const icons = tooltip.findAll(SeriesIcon); + expect(icons[0]).toHaveReactProps({color: FUNNEL_CHART_SEGMENT_FILL}); + expect(icons[1]).toHaveReactProps({color: FUNNEL_CHART_CONNECTOR_GRADIENT}); + }); +}); diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts index 406fe7f49..525f68a15 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/index.ts @@ -3,3 +3,4 @@ export {FunnelTooltip} from './FunnelTooltip'; export {TooltipWithPortal} from './TooltipWithPortal'; export {ScaleIcon} from './ScaleIcon'; export {ScaleIconTooltip} from './ScaleIconTooltip'; +export {Tooltip} from './Tooltip'; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx index 560268acc..a2ca4e8e5 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Default.stories.tsx @@ -20,6 +20,10 @@ const percentageFormatter = (value) => `${labelFormatter(value)}%`; Default.args = { data: DEFAULT_DATA, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, labelFormatter, percentageFormatter, renderScaleIconTooltipContent: () => ( diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx index 4042c9a36..cddd91139 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/FunnelChartNext.chromatic.stories.tsx @@ -25,4 +25,8 @@ const labelFormatter = (value) => { Default.args = { data: DEFAULT_DATA, labelFormatter, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, }; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx index 60ce842ea..644743155 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/Playground.stories.tsx @@ -44,4 +44,8 @@ ZeroValues.args = { }, ], labelFormatter, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, }; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx b/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx index f9975e7df..d392a0737 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/tests/Chart.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import {Chart} from '../Chart'; import {FunnelChartConnector, FunnelChartSegment} from '../../shared'; import {SingleTextLine} from '../../Labels'; +import {FunnelTooltip} from '../components'; const mockData: DataSeries[] = [ { @@ -29,6 +30,10 @@ const mockContext = { const defaultProps = { data: mockData, + tooltipLabels: { + reached: 'Reached this step', + dropped: 'Dropped off', + }, seriesNameFormatter: (value: string) => `$${value}`, labelFormatter: (value: string) => `$${value}`, }; @@ -70,4 +75,31 @@ describe('', () => { text: 'Custom Step 1', }); }); + + it('shows tooltip when hovering over a segment', () => { + const chart = mount( + + + , + ); + + const firstSegment = chart.find(FunnelChartSegment); + firstSegment?.trigger('onMouseEnter', 0); + + expect(chart).toContainReactComponent(FunnelTooltip); + }); + + it('hides tooltip when mouse leaves a segment', () => { + const chart = mount( + + + , + ); + + const firstSegment = chart.find(FunnelChartSegment); + firstSegment?.trigger('onMouseEnter', 0); + firstSegment?.trigger('onMouseLeave'); + + expect(chart).not.toContainReactComponent(FunnelTooltip); + }); });