From 3ff94005f5d1328d724e2c7309f83f89ad760ad3 Mon Sep 17 00:00:00 2001 From: Matt Vickers Date: Wed, 16 Oct 2024 15:33:37 -0500 Subject: [PATCH] Tweak tooltips --- .eslintrc | 3 +- .../src/components/FunnelChartNext/Chart.tsx | 174 +++++++++--------- .../FunnelChartNext/FunnelChartNext.tsx | 5 +- .../components/FunnelChartXAxisLabels.tsx | 27 +-- .../components/FunnelConnector.tsx | 83 +++------ .../FunnelTooltip/FunnelTooltip.scss | 10 + .../FunnelTooltip/FunnelTooltip.tsx | 33 ++++ .../components/Tooltip/Tooltip.tsx | 8 +- .../components/FunnelChartNext/constants.ts | 1 + .../FunnelChartNext/stories/meta.ts | 7 + .../utilities/get-tooltip-position.ts | 4 + .../TooltipWrapper/TooltipWrapper.tsx | 6 +- .../components/TooltipWrapper/constants.ts | 2 + .../FunnelChartSegment/FunnelChartSegment.tsx | 17 ++ 14 files changed, 222 insertions(+), 158 deletions(-) create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss create mode 100644 packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx diff --git a/.eslintrc b/.eslintrc index 44a5a01c7..b56308cf1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -81,7 +81,8 @@ "UNSTABLE_telemetry" ] } - ] + ], + "@shopify/strict-component-boundaries": "warn" }, "overrides": [ { diff --git a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx index 281aff310..a6cb38c2b 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/Chart.tsx @@ -1,3 +1,4 @@ +import type {ReactNode} from 'react'; import {Fragment, useMemo, useCallback, useState} from 'react'; import {scaleBand, scaleLinear} from 'd3-scale'; import type { @@ -6,32 +7,26 @@ import type { XAxisOptions, YAxisOptions, } from '@shopify/polaris-viz-core'; -import { - uniqueId, - LinearGradientWithStops, - DataType, -} from '@shopify/polaris-viz-core'; +import {uniqueId, LinearGradientWithStops} from '@shopify/polaris-viz-core'; +import {createPortal} from 'react-dom'; +import {TOOLTIP_ROOT_ID} from '../TooltipWrapper/constants'; +import {useRootContainer} from '../../hooks/useRootContainer'; import {FunnelChartConnectorGradient} from '../shared/FunnelChartConnector'; import {FunnelChartSegment} from '../shared'; -import type {TooltipPosition, TooltipPositionParams} from '../TooltipWrapper'; -import { - TOOLTIP_POSITION_DEFAULT_RETURN, - TooltipHorizontalOffset, - TooltipVerticalOffset, - TooltipWrapper, -} from '../TooltipWrapper'; import {SingleTextLine} from '../Labels'; import {ChartElements} from '../ChartElements'; import {FunnelChartXAxisLabels, Tooltip, FunnelConnector} from './components/'; -import {getTooltipPosition} from './utilities/get-tooltip-position'; import {calculateDropOff} from './utilities/calculate-dropoff'; import type {FunnelChartNextProps} from './FunnelChartNext'; import {getFunnelBarHeight} from './utilities/get-funnel-bar-height'; +import {FunnelTooltip} from './components/FunnelTooltip/FunnelTooltip'; +import {FUNNEL_CONNECTOR_Y_OFFSET, TOOLTIP_WIDTH} from './constants'; export interface ChartProps { data: DataSeries[]; + showConnectionPercentage: boolean; tooltipLabels: FunnelChartNextProps['tooltipLabels']; xAxisOptions: Required; yAxisOptions: Required; @@ -40,9 +35,11 @@ export interface ChartProps { const LINE_OFFSET = 3; const LINE_WIDTH = 1; - +const TOOLTIP_HEIGHT = 90; +const SHORT_TOOLTIP_HEIGHT = 65; const GAP = 1; +const PERCENTAGE_COLOR = 'rgba(48, 48, 48, 1)'; const LINE_GRADIENT = [ { color: 'rgba(227, 227, 227, 1)', @@ -60,25 +57,26 @@ const PERCENTAGE_SUMMARY_HEIGHT = 30; export function Chart({ data, dimensions, + showConnectionPercentage, tooltipLabels, xAxisOptions, yAxisOptions, }: ChartProps) { - const [svgRef, setSvgRef] = useState(null); + const [tooltipIndex, setTooltipIndex] = useState(null); const dataSeries = data[0].data; const xValues = dataSeries.map(({key}) => key) as string[]; const yValues = dataSeries.map(({value}) => value) as [number, number]; - const {width: drawableWidth, height: drawableHeight} = dimensions ?? { - width: 0, - height: 0, - }; - - const chartBounds: BoundingRect = { + const { width: drawableWidth, height: drawableHeight, + x: chartX, + y: chartY, + } = dimensions ?? { + width: 0, + height: 0, x: 0, y: 0, }; @@ -94,15 +92,22 @@ export function Chart({ .range([0, drawableWidth]) .domain(labels.map((_, index) => index.toString())); + const highestYValue = Math.max(...yValues); + const connectionPercentageHeight = showConnectionPercentage + ? FUNNEL_CONNECTOR_Y_OFFSET / 2 + : 0; + const yScale = scaleLinear() .range([ 0, drawableHeight - LABELS_HEIGHT - PERCENTAGE_SUMMARY_HEIGHT - - PERCENTAGE_SUMMARY_HEIGHT, + connectionPercentageHeight, ]) - .domain([0, Math.max(...yValues)]); + .domain([0, highestYValue]); + + const tallestBarHeight = yScale(highestYValue); const sectionWidth = xScale.bandwidth(); const barWidth = sectionWidth * 0.75; @@ -137,11 +142,7 @@ export function Chart({ ); return ( - + + {xAxisOptions.hide === false && ( { const nextPoint = dataSeries[index + 1]; - const xPosition = xScale(dataPoint.key as string); + const xPosition = xScale(dataPoint.key.toString()); const x = xPosition == null ? 0 : xPosition; const nextBarHeight = getBarHeight(nextPoint?.value || 0); @@ -200,6 +202,9 @@ export function Chart({ drawableHeight={drawableHeight} index={index} isLast={isLast} + onMouseEnter={(index) => setTooltipIndex(index)} + onMouseLeave={() => setTooltipIndex(null)} + tallestBarHeight={tallestBarHeight} x={x} > {!isLast && ( @@ -212,6 +217,7 @@ export function Chart({ } nextY={drawableHeight - nextBarHeight} percentCalculation={formattedPercent} + showConnectionPercentage={showConnectionPercentage} startX={x + barWidth + GAP} startY={drawableHeight - barHeight} width={sectionWidth - barWidth} @@ -220,9 +226,10 @@ export function Chart({ {index > 0 && ( )} @@ -230,69 +237,72 @@ export function Chart({ ); })} - + + {getTooltipMarkup()} ); - function getTooltipMarkup(index: number) { + function getTooltipMarkup() { + if (tooltipIndex == null) { + return null; + } + + const tooltipHeight = + tooltipIndex === dataSeries.length - 1 + ? SHORT_TOOLTIP_HEIGHT + : TOOLTIP_HEIGHT; + + const activeDataSeries = dataSeries[tooltipIndex]; + + if (activeDataSeries == null) { + return null; + } + + const xPosition = getXPosition(); + const yPosition = getYPosition(); + return ( - + + + ); - } - function formatPercentage(value: number) { - return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; - } + function getXPosition() { + if (tooltipIndex === 0) { + // Push the tooltip beside the bar + return chartX + barWidth + 10; + } - function formatPositionForTooltip(index: number | null): TooltipPosition { - // Don't render the tooltip for the first bar - if ((index === 0 && xAxisOptions.hide === false) || index == null) { - return TOOLTIP_POSITION_DEFAULT_RETURN; + // Center the tooltip over the bar + const xOffset = (barWidth - TOOLTIP_WIDTH) / 2; + return chartX + (xScale(activeDataSeries.key.toString()) ?? 0) + xOffset; } - const xOffset = (sectionWidth - barWidth) / 2; - const x = labelXScale(`${index}`) ?? 0; + function getYPosition() { + const yPosition = + chartY + drawableHeight - yScale(activeDataSeries.value ?? 0); - const y = drawableHeight - yScale(dataSeries[index].value ?? 0); + if (tooltipIndex === 0) { + return yPosition; + } - return { - x: x - xOffset + (dimensions?.x ?? 0), - y: Math.abs(y) + (dimensions?.y ?? 0), - position: { - horizontal: TooltipHorizontalOffset.Center, - vertical: TooltipVerticalOffset.Above, - }, - activeIndex: index, - }; + return yPosition - tooltipHeight; + } } - function getPosition({ - event, - index, - eventType, - }: TooltipPositionParams): TooltipPosition { - return getTooltipPosition({ - tooltipPosition: {event, index, eventType}, - formatPositionForTooltip, - maxIndex: dataSeries.length - 1, - step: xScale.step(), - yMax: drawableHeight, - }); + function formatPercentage(value: number) { + return `${yAxisOptions.labelFormatter(isNaN(value) ? 0 : value)}%`; } } + +function TooltipWithPortal({children}: {children: ReactNode}) { + const container = useRootContainer(TOOLTIP_ROOT_ID); + + return createPortal(children, container); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx index 3d2dd7ecb..0f7448262 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/FunnelChartNext.tsx @@ -19,6 +19,7 @@ import {ChartSkeleton} from '../'; import {Chart} from './Chart'; export type FunnelChartNextProps = { + showConnectionPercentage?: boolean; tooltipLabels: { reached: string; dropped: string; @@ -40,6 +41,7 @@ export function FunnelChartNext(props: FunnelChartNextProps) { state, errorText, onError, + showConnectionPercentage = false, tooltipLabels, } = { ...DEFAULT_CHART_PROPS, @@ -70,9 +72,10 @@ export function FunnelChartNext(props: FunnelChartNextProps) { ) : ( )} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx index 8cab93de3..08b376ff4 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelChartXAxisLabels.tsx @@ -9,6 +9,12 @@ const LINE_GAP = 5; const LINE_PADDING = 10; const GROUP_OFFSET = 10; const LABEL_FONT_SIZE = 12; +const PERCENT_FONT_SIZE = 14; +const PERCENT_FONT_WEIGHT = 650; +const VALUE_FONT_SIZE = 11; + +const TEXT_COLOR = 'rgba(31, 33, 36, 1)'; +const VALUE_COLOR = 'rgba(97, 97, 97, 1)'; export interface FunnelChartXAxisLabelsProps { formattedValues: string[]; @@ -32,15 +38,10 @@ export function FunnelChartXAxisLabels({ {labels.map((label, index) => { const x = xScale(index.toString()) ?? 0; - // const firstLabelHeight = line.reduce( - // (acc, {height}) => acc + height, - // 0, - // ); - const percentWidth = estimateStringWidthWithOffset( percentages[index], - 14, - 650, + PERCENT_FONT_SIZE, + PERCENT_FONT_WEIGHT, ); return ( @@ -51,7 +52,7 @@ export function FunnelChartXAxisLabels({ key={index} > diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx index e4bba5822..2822836a4 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelConnector.tsx @@ -1,14 +1,11 @@ -import {Fragment, useState} from 'react'; -import {useSpring, animated} from '@react-spring/web'; +import {Fragment} from 'react'; import {FONT_SIZE} from '@shopify/polaris-viz-core'; import {FunnelChartConnector} from '../../shared'; import {estimateStringWidthWithOffset} from '../../../utilities'; import {SingleTextLine} from '../../Labels'; -import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig'; import {FUNNEL_CONNECTOR_Y_OFFSET} from '../constants'; -const ANIMATION_DELAY = 150; const TEXT_HEIGHT = 10; const TEXT_PADDING = 4; @@ -19,28 +16,24 @@ interface ConnectorProps { nextX: number; nextY: number; percentCalculation: string; + showConnectionPercentage: boolean; startX: number; startY: number; width: number; } export function FunnelConnector({ + drawableHeight, height, + index, nextX, nextY, + percentCalculation, + showConnectionPercentage, startX, startY, width, - drawableHeight, - index, - percentCalculation, }: ConnectorProps) { - const [isHovering, setIsHovering] = useState(false); - - const springConfig = useBarSpringConfig({ - animationDelay: index * ANIMATION_DELAY, - }); - const textWidth = estimateStringWidthWithOffset( percentCalculation, FONT_SIZE, @@ -49,38 +42,33 @@ export function FunnelConnector({ const pillX = startX + width / 2 - textWidth / 2 - TEXT_PADDING; - const yOffset = isHovering ? FUNNEL_CONNECTOR_Y_OFFSET : 0; - - const {pillTransform, pillOpacity} = useSpring({ - pillTransform: `translate(${pillX}px, ${startY - yOffset}px)`, - pillOpacity: isHovering ? 1 : 0, - ...springConfig, - }); - const doubleTextPadding = TEXT_PADDING * 2; return ( - - - - + {showConnectionPercentage && ( + + + + + )} - - setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - onFocus={() => setIsHovering(true)} - onBlur={() => setIsHovering(false)} - tabIndex={0} - /> ); } diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss new file mode 100644 index 000000000..ddfebf4a3 --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.scss @@ -0,0 +1,10 @@ +.Tooltip { + top: 0; + left: 0; + position: absolute; + pointer-events: none; + // Matches --p-z-index-12 + // https://polaris.shopify.com/tokens/z-index + z-index: 520; + max-width: 70%; +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx new file mode 100644 index 000000000..645bbeefa --- /dev/null +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/FunnelTooltip/FunnelTooltip.tsx @@ -0,0 +1,33 @@ +import type {ReactNode} from 'react'; +import {animated, useSpring} from '@react-spring/web'; + +import {FUNNEL_CONNECTOR_Y_OFFSET} from '../../constants'; + +import styles from './FunnelTooltip.scss'; + +export function FunnelTooltip({ + children, + x, + y, +}: { + children: ReactNode; + x: number; + y: number; +}) { + const {transform, opacity} = useSpring({ + from: { + transform: `translate(${x}px, ${y + FUNNEL_CONNECTOR_Y_OFFSET}px)`, + opacity: 0, + }, + to: { + transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`, + opacity: 1, + }, + }); + + return ( + + {children} + + ); +} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx index 5b1ff7ea8..2e5a05ab8 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx +++ b/packages/polaris-viz/src/components/FunnelChartNext/components/Tooltip/Tooltip.tsx @@ -2,6 +2,7 @@ import {Fragment} from 'react'; import type {Color, DataPoint, YAxisOptions} from '@shopify/polaris-viz-core'; import {DEFAULT_THEME_NAME} from '@shopify/polaris-viz-core'; +import {TOOLTIP_WIDTH} from '../../constants'; import {FUNNEL_CHART_CONNECTOR_GRADIENT} from '../../../shared/FunnelChartConnector'; import {FUNNEL_CHART_SEGMENT_FILL} from '../../../shared/FunnelChartSegment'; import type {FunnelChartNextProps} from '../../FunnelChartNext'; @@ -19,8 +20,6 @@ export interface TooltipContentProps { yAxisOptions: Required; } -const MAX_WIDTH = 300; - interface Data { key: string; value: string; @@ -63,7 +62,10 @@ export function Tooltip({ } return ( - + {() => ( {point.key} diff --git a/packages/polaris-viz/src/components/FunnelChartNext/constants.ts b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts index f6d94fdb6..9abea2fad 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/constants.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/constants.ts @@ -1 +1,2 @@ export const FUNNEL_CONNECTOR_Y_OFFSET = 30; +export const TOOLTIP_WIDTH = 250; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts index b08bc538a..a5a16cc4d 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/stories/meta.ts @@ -27,5 +27,12 @@ export const META: Meta = { yAxisOptions: Y_AXIS_OPTIONS_ARGS, theme: THEME_CONTROL_ARGS, state: CHART_STATE_CONTROL_ARGS, + showConnectionPercentage: { + description: + 'Show the percentage change between each segment in the funnel.', + control: { + type: 'boolean', + }, + }, }, }; diff --git a/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts index e7539d60e..f2d1533a3 100644 --- a/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts +++ b/packages/polaris-viz/src/components/FunnelChartNext/utilities/get-tooltip-position.ts @@ -32,6 +32,10 @@ export function getTooltipPosition({ const {svgX, svgY} = point; + console.log({svgX}); + + console.log({step}); + const activeIndex = Math.floor(svgX / step); if (activeIndex < 0 || activeIndex > maxIndex || svgY <= 0 || svgY > yMax) { diff --git a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx index 250eb5ac8..8eb202a46 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx +++ b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx @@ -10,12 +10,10 @@ import {SwallowErrors} from '../SwallowErrors'; import {shouldBlockTooltipEvents} from './utilities/shouldBlockTooltipEvents'; import type {TooltipPosition, TooltipPositionParams} from './types'; -import {DEFAULT_TOOLTIP_POSITION} from './constants'; +import {DEFAULT_TOOLTIP_POSITION, TOOLTIP_ROOT_ID} from './constants'; import {TooltipAnimatedContainer} from './components/TooltipAnimatedContainer'; import type {AlteredPosition} from './utilities'; -const TOOLTIP_ID = 'polaris_viz_tooltip_root'; - interface BaseProps { chartBounds: BoundingRect; getMarkup: (index: number) => ReactNode; @@ -223,7 +221,7 @@ function TooltipWithErrors(props: BaseProps) { } function TooltipWithPortal(props: BaseProps) { - const container = useRootContainer(TOOLTIP_ID); + const container = useRootContainer(TOOLTIP_ROOT_ID); return createPortal(, container); } diff --git a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts index a7e43b45e..7bd221f36 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/constants.ts +++ b/packages/polaris-viz/src/components/TooltipWrapper/constants.ts @@ -12,3 +12,5 @@ export const TOOLTIP_POSITION_DEFAULT_RETURN: TooltipPosition = { position: DEFAULT_TOOLTIP_POSITION, activeIndex: null, }; + +export const TOOLTIP_ROOT_ID = 'polaris_viz_tooltip_root'; diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx index 2dbfe0425..415e590bc 100644 --- a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx +++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx @@ -17,6 +17,9 @@ export interface Props { drawableHeight: number; index: number; isLast: boolean; + onMouseEnter: (index: number) => void; + onMouseLeave: () => void; + tallestBarHeight: number; x: number; } @@ -28,6 +31,9 @@ export function FunnelChartSegment({ drawableHeight, index = 0, isLast, + onMouseEnter, + onMouseLeave, + tallestBarHeight, x, }: Props) { const mounted = useRef(false); @@ -66,6 +72,17 @@ export function FunnelChartSegment({ ), }} /> + + onMouseEnter(index)} + onMouseLeave={onMouseLeave} + /> + {children} );