diff --git a/packages/polaris-viz-core/src/contexts/ChartContext.tsx b/packages/polaris-viz-core/src/contexts/ChartContext.tsx index 9e1c8137e..a5006b4a4 100644 --- a/packages/polaris-viz-core/src/contexts/ChartContext.tsx +++ b/packages/polaris-viz-core/src/contexts/ChartContext.tsx @@ -10,6 +10,7 @@ export interface ChartContextValues { containerBounds: BoundingRect; shouldAnimate: boolean; theme: string; + isTouchDevice: boolean; isPerformanceImpacted: boolean; scrollContainer?: Element | null; } @@ -24,5 +25,6 @@ export const ChartContext = createContext({ containerBounds: {height: 0, width: 0, x: 0, y: 0}, shouldAnimate: true, theme: DEFAULT_THEME_NAME, + isTouchDevice: false, isPerformanceImpacted: false, }); diff --git a/packages/polaris-viz-core/src/index.ts b/packages/polaris-viz-core/src/index.ts index 4dc38802b..4011c90d5 100644 --- a/packages/polaris-viz-core/src/index.ts +++ b/packages/polaris-viz-core/src/index.ts @@ -101,6 +101,7 @@ export { getGradientFromColor, OpacityScale, isInfinity, + isTouchDevice, } from './utilities'; export { useSparkBar, diff --git a/packages/polaris-viz-core/src/utilities/index.ts b/packages/polaris-viz-core/src/utilities/index.ts index a3fbe7b90..5c26c0a4f 100644 --- a/packages/polaris-viz-core/src/utilities/index.ts +++ b/packages/polaris-viz-core/src/utilities/index.ts @@ -27,3 +27,4 @@ export {ColorScale} from './ColorScale/ColorScale'; export {OpacityScale} from './OpacityScale/OpacityScale'; export {isDataGroupArray} from './isDataGroup'; export {getGradientFromColor} from './getGradientFromColor'; +export {isTouchDevice} from './isTouchDevice'; diff --git a/packages/polaris-viz-core/src/utilities/isTouchDevice.ts b/packages/polaris-viz-core/src/utilities/isTouchDevice.ts new file mode 100644 index 000000000..163f669aa --- /dev/null +++ b/packages/polaris-viz-core/src/utilities/isTouchDevice.ts @@ -0,0 +1,3 @@ +export function isTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +} diff --git a/packages/polaris-viz/CHANGELOG.md b/packages/polaris-viz/CHANGELOG.md index 1d598592c..7f5f87e12 100644 --- a/packages/polaris-viz/CHANGELOG.md +++ b/packages/polaris-viz/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ### Changed - Refactored containers bounds into a hook and moved to chart context. `` now uses the new `useContainerBounds` hook. +- Tooltips on touch devices now only show after a short delay. ## [15.3.2] - 2024-11-20 diff --git a/packages/polaris-viz/src/chromatic/components/CombinationRenderer.tsx b/packages/polaris-viz/src/chromatic/components/CombinationRenderer.tsx index ebb302874..64b31ffdc 100644 --- a/packages/polaris-viz/src/chromatic/components/CombinationRenderer.tsx +++ b/packages/polaris-viz/src/chromatic/components/CombinationRenderer.tsx @@ -26,6 +26,7 @@ export function CombinationRenderer({ return { ...DEFAULT_CHART_CONTEXT, theme: theme ?? DEFAULT_THEME_NAME, + isTouchDevice: false, }; }, [theme]); diff --git a/packages/polaris-viz/src/components/ChartContainer/ChartContainer.tsx b/packages/polaris-viz/src/components/ChartContainer/ChartContainer.tsx index fd9810b71..847587099 100644 --- a/packages/polaris-viz/src/components/ChartContainer/ChartContainer.tsx +++ b/packages/polaris-viz/src/components/ChartContainer/ChartContainer.tsx @@ -12,7 +12,9 @@ import { ChartContext, isLargeDataSet, usePolarisVizContext, + isTouchDevice, } from '@shopify/polaris-viz-core'; +import type {ChartContextValues} from '@shopify/polaris-viz-core/src/contexts'; import {EMPTY_BOUNDS} from '../../constants'; import {ChartErrorBoundary} from '../ChartErrorBoundary'; @@ -49,13 +51,13 @@ export const ChartContainer = (props: Props) => { return isLargeDataSet(props.data, props.type); }, [props.data, props.type]); - const {containerBounds, onMouseEnter, setRef} = useContainerBounds({ + const {containerBounds, updateContainerBounds, setRef} = useContainerBounds({ onIsPrintingChange: setIsPrinting, scrollContainer: props.scrollContainer, sparkChart: props.sparkChart, }); - const value = useMemo(() => { + const value: ChartContextValues = useMemo(() => { const shouldAnimate = props.isAnimated && !prefersReducedMotion && !dataTooBigToAnimate; const printFriendlyTheme = isPrinting ? 'Print' : props.theme; @@ -66,6 +68,7 @@ export const ChartContainer = (props: Props) => { characterWidths, characterWidthOffsets, theme: printFriendlyTheme, + isTouchDevice: isTouchDevice(), isPerformanceImpacted: dataTooBigToAnimate, scrollContainer: props.scrollContainer, containerBounds: containerBounds ?? EMPTY_BOUNDS, @@ -109,8 +112,9 @@ export const ChartContainer = (props: Props) => { ? chartContainer.sparkChartMinHeight : chartContainer.minHeight, }} - onMouseEnter={onMouseEnter} - onFocus={onMouseEnter} + onMouseEnter={updateContainerBounds} + onFocus={updateContainerBounds} + onTouchStart={updateContainerBounds} > {!hasValidBounds(value.containerBounds) ? null : ( { + const updateContainerBounds = useCallback(() => { if (ref == null) { return; } @@ -115,5 +115,9 @@ export function useContainerBounds({ }); }, [ref, scrollContainer]); - return {containerBounds, setContainerBounds, onMouseEnter, setRef}; + return { + containerBounds, + updateContainerBounds, + setRef, + }; } diff --git a/packages/polaris-viz/src/components/ComboChart/Chart.tsx b/packages/polaris-viz/src/components/ComboChart/Chart.tsx index e6203d9cc..eeb46c2df 100644 --- a/packages/polaris-viz/src/components/ComboChart/Chart.tsx +++ b/packages/polaris-viz/src/components/ComboChart/Chart.tsx @@ -327,7 +327,7 @@ export function Chart({ longestSeriesIndex={0} margin={ChartMargin} onIndexChange={(index) => setActiveIndex(index)} - parentRef={svgRef} + parentElement={svgRef} xScale={barXScale} yScale={barYScale} /> diff --git a/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx b/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx index e7786c026..d79ebaa5e 100644 --- a/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx @@ -8,6 +8,7 @@ import { useAriaLabel, LINE_HEIGHT, InternalChartType, + useChartContext, } from '@shopify/polaris-viz-core'; import type { DataSeries, @@ -77,6 +78,8 @@ export function Chart({ xAxisOptions, yAxisOptions, }: ChartProps) { + const {isTouchDevice} = useChartContext(); + useColorVisionEvents({enabled: data.length > 1}); const selectedTheme = useTheme(); @@ -297,7 +300,7 @@ export function Chart({ focusElementDataType={DataType.BarGroup} getMarkup={getTooltipMarkup} margin={ChartMargin} - parentRef={svgRef} + parentElement={svgRef} longestSeriesIndex={longestSeriesIndex} xScale={xScale} type={type} diff --git a/packages/polaris-viz/src/components/LineChart/Chart.tsx b/packages/polaris-viz/src/components/LineChart/Chart.tsx index df6f8decc..eb92914eb 100644 --- a/packages/polaris-viz/src/components/LineChart/Chart.tsx +++ b/packages/polaris-viz/src/components/LineChart/Chart.tsx @@ -95,13 +95,14 @@ export function Chart({ yAxisOptions, }: ChartProps) { const selectedTheme = useTheme(theme); - const {isPerformanceImpacted, containerBounds} = useChartContext(); + const {isPerformanceImpacted, containerBounds, isTouchDevice} = + useChartContext(); const [activeIndex, setActiveIndex] = useState(null); const [activeLineIndex, setActiveLineIndex] = useState(-1); useColorVisionEvents({ - enabled: data.length > 1, + enabled: data.length > 1 && !isTouchDevice, }); const isSmallChart = containerBounds.height < SMALL_CHART_HEIGHT; @@ -386,7 +387,7 @@ export function Chart({ setActiveIndex(index); } }} - parentRef={svgRef} + parentElement={svgRef} usePortal xScale={xScale} yScale={yScale} diff --git a/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx b/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx index 53e95dd92..164f3179f 100644 --- a/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx @@ -3,6 +3,7 @@ import { uniqueId, COLOR_VISION_SINGLE_ITEM, useAriaLabel, + useChartContext, } from '@shopify/polaris-viz-core'; import type { ChartType, @@ -53,7 +54,8 @@ export function Chart({ xAxisOptions, yAxisOptions, }: ChartProps) { - useColorVisionEvents({enabled: data.length > 1}); + const {isTouchDevice} = useChartContext(); + useColorVisionEvents({enabled: data.length > 1 && !isTouchDevice}); const fontSize = getFontSize(); const id = useMemo(() => uniqueId('SimpleBarChart'), []); diff --git a/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx b/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx index 921bb3186..90a43f8cb 100644 --- a/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx +++ b/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx @@ -82,12 +82,12 @@ export function Chart({ }: Props) { const selectedTheme = useTheme(theme); const seriesColors = useThemeSeriesColors(data, selectedTheme); - const {containerBounds} = useChartContext(); + const {containerBounds, isTouchDevice} = useChartContext(); const [activePointIndex, setActivePointIndex] = useState(null); const [svgRef, setSvgRef] = useState(null); - useColorVisionEvents({enabled: data.length > 1}); + useColorVisionEvents({enabled: data.length > 1 && !isTouchDevice}); const isSmallChart = containerBounds.height < SMALL_CHART_HEIGHT; @@ -383,7 +383,7 @@ export function Chart({ longestSeriesIndex={longestSeriesIndex} margin={ChartMargin} onIndexChange={(index) => setActivePointIndex(index)} - parentRef={svgRef} + parentElement={svgRef} usePortal xScale={xScale} /> diff --git a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx index d6cbedf1f..f1663fca0 100644 --- a/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx +++ b/packages/polaris-viz/src/components/TooltipWrapper/TooltipWrapper.tsx @@ -23,6 +23,8 @@ import type {TooltipPosition} from './types'; import {DEFAULT_TOOLTIP_POSITION} from './constants'; import {TooltipAnimatedContainer} from './components/TooltipAnimatedContainer'; +const TOUCH_START_DELAY = 300; + interface BaseProps { chartBounds: BoundingRect; chartType: InternalChartType; @@ -31,7 +33,7 @@ interface BaseProps { getMarkup: (index: number) => ReactNode; longestSeriesIndex: number; margin: Margin; - parentRef: SVGSVGElement | null; + parentElement: SVGSVGElement | null; xScale: ScaleLinear | ScaleBand; bandwidth?: number; onIndexChange?: (index: number | null) => void; @@ -50,12 +52,12 @@ function TooltipWrapperRaw(props: BaseProps) { id, longestSeriesIndex, onIndexChange, - parentRef, + parentElement, type, xScale, yScale, } = props; - const {scrollContainer} = useChartContext(); + const {scrollContainer, isTouchDevice} = useChartContext(); const [position, setPosition] = useState({ x: 0, y: 0, @@ -64,18 +66,21 @@ function TooltipWrapperRaw(props: BaseProps) { }); const activeIndexRef = useRef(null); + const touchStartTimer = useRef(0); + const isLongTouch = useRef(false); const focusElements = useMemo | undefined>(() => { - return parentRef?.querySelectorAll( + return parentElement?.querySelectorAll( `[data-type="${focusElementDataType}"][aria-hidden="false"]`, ); - }, [focusElementDataType, parentRef]); + }, [focusElementDataType, parentElement]); useEffect(() => { activeIndexRef.current = position.activeIndex; }, [position.activeIndex]); - const alwaysUpdatePosition = chartType === InternalChartType.Line; + const alwaysUpdatePosition = + chartType === InternalChartType.Line && !isTouchDevice; const getPosition = useCallback( ({ @@ -88,12 +93,12 @@ function TooltipWrapperRaw(props: BaseProps) { index?: number; }) => { const containerBounds = { - x: parentRef?.getBoundingClientRect().x ?? 0, + x: parentElement?.getBoundingClientRect().x ?? 0, y: - Number(parentRef?.getBoundingClientRect().y ?? 0) + + Number(parentElement?.getBoundingClientRect().y ?? 0) + Number(scrollContainer?.scrollTop ?? 0), - width: parentRef?.getBoundingClientRect().width ?? 0, - height: parentRef?.getBoundingClientRect().height ?? 0, + width: parentElement?.getBoundingClientRect().width ?? 0, + height: parentElement?.getBoundingClientRect().height ?? 0, }; switch (chartType) { case InternalChartType.Line: @@ -138,7 +143,7 @@ function TooltipWrapperRaw(props: BaseProps) { chartType, data, longestSeriesIndex, - parentRef, + parentElement, scrollContainer?.scrollTop, type, xScale, @@ -146,7 +151,7 @@ function TooltipWrapperRaw(props: BaseProps) { ], ); - const onMouseMove = useCallback( + const showAndPositionTooltip = useCallback( (event: MouseEvent | TouchEvent) => { const newPosition = getPosition({event, eventType: 'mouse'}); @@ -183,13 +188,46 @@ function TooltipWrapperRaw(props: BaseProps) { ], ); + const onMouseMove = useCallback( + (event: MouseEvent | TouchEvent) => { + window.clearTimeout(touchStartTimer.current); + + if (event instanceof TouchEvent) { + if (isLongTouch.current === true) { + // prevents scrolling after long touch (since it is supposed to move the tooltip/datapoint vs scroll) + event?.preventDefault(); + } else { + return; + } + } + + showAndPositionTooltip(event); + }, + [showAndPositionTooltip], + ); + const onMouseLeave = useCallback(() => { + isLongTouch.current = false; + window.clearTimeout(touchStartTimer.current); onIndexChange?.(null); setPosition((prevState) => { return {...prevState, activeIndex: -1}; }); }, [onIndexChange]); + const onTouchStart = useCallback( + (event: TouchEvent) => { + touchStartTimer.current = window.setTimeout(() => { + event.preventDefault(); + + isLongTouch.current = true; + + showAndPositionTooltip(event); + }, TOUCH_START_DELAY); + }, + [showAndPositionTooltip], + ); + const onFocus = useCallback( (event: FocusEvent) => { const target = event.currentTarget as SVGSVGElement; @@ -208,10 +246,10 @@ function TooltipWrapperRaw(props: BaseProps) { ); const onFocusIn = useCallback(() => { - if (!parentRef?.contains(document.activeElement)) { + if (!parentElement?.contains(document.activeElement)) { onMouseLeave(); } - }, [parentRef, onMouseLeave]); + }, [parentElement, onMouseLeave]); const setFocusListeners = useCallback( (attach: boolean) => { @@ -231,26 +269,34 @@ function TooltipWrapperRaw(props: BaseProps) { ); useEffect(() => { - if (!parentRef) { + if (!parentElement) { return; } - parentRef.addEventListener('mousemove', onMouseMove); - parentRef.addEventListener('mouseleave', onMouseLeave); - parentRef.addEventListener('touchmove', onMouseMove); - parentRef.addEventListener('touchend', onMouseLeave); + parentElement.addEventListener('mousemove', onMouseMove); + parentElement.addEventListener('mouseleave', onMouseLeave); + parentElement.addEventListener('touchstart', onTouchStart); + parentElement.addEventListener('touchmove', onMouseMove); + parentElement.addEventListener('touchend', onMouseLeave); setFocusListeners(true); return () => { - parentRef.removeEventListener('mousemove', onMouseMove); - parentRef.removeEventListener('mouseleave', onMouseLeave); - parentRef.removeEventListener('touchmove', onMouseMove); - parentRef.removeEventListener('touchend', onMouseLeave); + parentElement.removeEventListener('mousemove', onMouseMove); + parentElement.removeEventListener('mouseleave', onMouseLeave); + parentElement.removeEventListener('touchstart', onTouchStart); + parentElement.removeEventListener('touchmove', onMouseMove); + parentElement.removeEventListener('touchend', onMouseLeave); setFocusListeners(false); }; - }, [parentRef, onMouseMove, onMouseLeave, onFocus, setFocusListeners]); + }, [ + parentElement, + onMouseLeave, + onTouchStart, + setFocusListeners, + onMouseMove, + ]); useEffect(() => { document.addEventListener('focusin', onFocusIn); @@ -258,7 +304,7 @@ function TooltipWrapperRaw(props: BaseProps) { return () => { document.removeEventListener('focusin', onFocusIn); }; - }, [parentRef, onFocusIn]); + }, [parentElement, onFocusIn]); if (position.activeIndex == null || position.activeIndex < 0) { return null; diff --git a/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx b/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx index dc0e6e61f..3e4015591 100644 --- a/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx @@ -343,7 +343,7 @@ export function Chart({ getMarkup={getTooltipMarkup} longestSeriesIndex={indexForLabels} margin={{...ChartMargin, Top: chartYPosition}} - parentRef={svgRef} + parentElement={svgRef} type={type} usePortal xScale={xScale} diff --git a/packages/polaris-viz/src/storybook/constants.ts b/packages/polaris-viz/src/storybook/constants.ts index 15db296c1..c22a51996 100644 --- a/packages/polaris-viz/src/storybook/constants.ts +++ b/packages/polaris-viz/src/storybook/constants.ts @@ -148,5 +148,6 @@ export const DEFAULT_CHART_CONTEXT: ChartContextValues = { containerBounds: {width: 400, height: 200, x: 0, y: 0}, id: '', isPerformanceImpacted: false, + isTouchDevice: false, theme: DEFAULT_THEME_NAME, };