From fba7688b51b710725eb775c676cc7ecdd37ff517 Mon Sep 17 00:00:00 2001 From: Susie Kim Date: Tue, 16 Jan 2024 11:58:25 -0500 Subject: [PATCH] Add hidden legend overflow styles to LineChart, VerticalBarChart, and StackedAreaChart --- packages/polaris-viz/CHANGELOG.md | 7 +- .../src/components/DonutChart/Chart.tsx | 2 +- .../components/HorizontalBarChart/Chart.tsx | 2 +- .../src/components/Legend/Legend.tsx | 21 +- .../components/LegendItem/LegendItem.scss | 7 + .../components/LegendItem/LegendItem.tsx | 55 +++++- .../Legend/components/LegendItem/index.ts | 1 + .../LegendItem/test/LegendItem.test.tsx | 93 ++++++++- .../src/components/Legend/components/index.ts | 1 + .../src/components/Legend/index.ts | 1 + .../components/Legend/tests/Legend.test.tsx | 24 +++ .../LegendContainer/LegendContainer.scss | 1 - .../LegendContainer/LegendContainer.tsx | 92 ++++++++- .../components/HiddenLegendTooltip.scss | 18 ++ .../components/HiddenLegendTooltip.tsx | 176 +++++++++++++++++ .../tests/HiddenLegendTooltip.test.tsx | 180 ++++++++++++++++++ .../tests/LegendsContainer.test.tsx | 109 ++++++++++- .../src/components/LineChart/Chart.tsx | 12 +- .../src/components/LineChart/LineChart.tsx | 3 + .../stories/SeriesColors.stories.tsx | 4 +- .../stories/playground/Playground.stories.tsx | 46 ++++- .../src/components/SimpleBarChart/Chart.tsx | 2 +- .../src/components/StackedAreaChart/Chart.tsx | 5 +- .../src/components/VerticalBarChart/Chart.tsx | 5 +- .../ColorVisionA11y/useColorVisionEvents.ts | 15 +- packages/polaris-viz/src/types.ts | 2 + 26 files changed, 850 insertions(+), 34 deletions(-) create mode 100644 packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.scss create mode 100644 packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.tsx create mode 100644 packages/polaris-viz/src/components/LegendContainer/components/tests/HiddenLegendTooltip.test.tsx diff --git a/packages/polaris-viz/CHANGELOG.md b/packages/polaris-viz/CHANGELOG.md index 3042431b0..3549efd81 100644 --- a/packages/polaris-viz/CHANGELOG.md +++ b/packages/polaris-viz/CHANGELOG.md @@ -5,7 +5,12 @@ 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 + +### Changed + +- Hide overflowing LegendItems in LineChart, VerticalBarChart, and StackedAreaChart legends. +- Update useColorVisionEvents to accept a root prop ## [10.3.2] - 2024-01-22 diff --git a/packages/polaris-viz/src/components/DonutChart/Chart.tsx b/packages/polaris-viz/src/components/DonutChart/Chart.tsx index 27089445f..2eeffd11e 100644 --- a/packages/polaris-viz/src/components/DonutChart/Chart.tsx +++ b/packages/polaris-viz/src/components/DonutChart/Chart.tsx @@ -132,7 +132,7 @@ export function Chart({ width && height && isLegendMounted, ); - useColorVisionEvents(shouldUseColorVisionEvents); + useColorVisionEvents({enabled: shouldUseColorVisionEvents}); useWatchColorVisionEvents({ type: COLOR_VISION_SINGLE_ITEM, diff --git a/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx b/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx index 0006ad663..3807c7e40 100644 --- a/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/HorizontalBarChart/Chart.tsx @@ -79,7 +79,7 @@ export function Chart({ xAxisOptions, yAxisOptions, }: ChartProps) { - useColorVisionEvents(data.length > 1); + useColorVisionEvents({enabled: data.length > 1}); const selectedTheme = useTheme(); const id = useMemo(() => uniqueId('HorizontalBarChart'), []); diff --git a/packages/polaris-viz/src/components/Legend/Legend.tsx b/packages/polaris-viz/src/components/Legend/Legend.tsx index 4bb9e2be9..53a8dda56 100644 --- a/packages/polaris-viz/src/components/Legend/Legend.tsx +++ b/packages/polaris-viz/src/components/Legend/Legend.tsx @@ -1,16 +1,22 @@ import {Fragment} from 'react'; +import type {RefObject} from 'react'; import {DEFAULT_THEME_NAME} from '@shopify/polaris-viz-core'; import {useExternalHideEvents} from '../../hooks'; import type {LegendData} from '../../types'; -import {LegendItem} from './components/'; +import {LegendItem} from './components'; +import type {LegendItemDimension} from './components'; export interface LegendProps { data: LegendData[]; activeIndex?: number; colorVisionType?: string; theme?: string; + itemDimensions?: RefObject; + backgroundColor?: string; + indexOffset?: number; + truncate?: boolean; } export function Legend({ @@ -18,6 +24,10 @@ export function Legend({ colorVisionType, data, theme = DEFAULT_THEME_NAME, + itemDimensions, + indexOffset = 0, + backgroundColor, + truncate = false, }: LegendProps) { const {hiddenIndexes} = useExternalHideEvents(); @@ -32,8 +42,15 @@ export function Legend({ {...legend} activeIndex={activeIndex} colorVisionType={colorVisionType} - index={index} + index={index + indexOffset} theme={theme} + backgroundColor={backgroundColor} + onDimensionChange={(dimensions) => { + if (itemDimensions?.current) { + itemDimensions.current[index + indexOffset] = dimensions; + } + }} + truncate={truncate} /> ); }); diff --git a/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.scss b/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.scss index e3f78c55f..e59411823 100644 --- a/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.scss +++ b/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.scss @@ -18,6 +18,13 @@ margin: -2px 0; font-size: 12px; font-family: $font-stack-base; + white-space: nowrap; + min-width: 0; +} + +.Text { + overflow: hidden; + text-overflow: ellipsis; } .IconContainer { diff --git a/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.tsx b/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.tsx index 5ff8bd0ac..f5d45219e 100644 --- a/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.tsx +++ b/packages/polaris-viz/src/components/Legend/components/LegendItem/LegendItem.tsx @@ -3,6 +3,7 @@ import { getColorVisionStylesForActiveIndex, } from '@shopify/polaris-viz-core'; import type {ReactNode} from 'react'; +import {useEffect, useRef, useState} from 'react'; import { LEGEND_ITEM_LEFT_PADDING, @@ -16,12 +17,23 @@ import {useTheme} from '../../../../hooks'; import style from './LegendItem.scss'; +export interface LegendItemDimension { + width: number; + height: number; +} + +export const MINIMUM_LEGEND_ITEM_WIDTH = 100; +export const MINIMUM_LEGEND_ITEM_WITH_VALUE_WIDTH = 200; + export interface LegendItemProps extends LegendData { index: number; activeIndex?: number; colorVisionType?: string; renderSeriesIcon?: () => ReactNode; theme?: string; + onDimensionChange?: ({width, height}: LegendItemDimension) => void; + backgroundColor?: string; + truncate?: boolean; } export function LegendItem({ @@ -35,8 +47,26 @@ export function LegendItem({ shape, theme, value, + onDimensionChange, + backgroundColor, + truncate = false, }: LegendItemProps) { const selectedTheme = useTheme(theme); + const ref = useRef(null); + const [width, setWidth] = useState(0); + + const minWidth = + value != null + ? MINIMUM_LEGEND_ITEM_WITH_VALUE_WIDTH + : MINIMUM_LEGEND_ITEM_WIDTH; + + useEffect(() => { + if (onDimensionChange && ref.current != null) { + const {width, height} = ref.current.getBoundingClientRect(); + setWidth(width); + onDimensionChange({width: Math.min(minWidth, Math.round(width)), height}); + } + }, [onDimensionChange, ref, minWidth]); const colorBlindAttrs = colorVisionType == null @@ -46,11 +76,13 @@ export function LegendItem({ index, }); + const background = backgroundColor ?? selectedTheme.legend.backgroundColor; + return ( diff --git a/packages/polaris-viz/src/components/Legend/components/LegendItem/index.ts b/packages/polaris-viz/src/components/Legend/components/LegendItem/index.ts index b4b0902d6..57ab71165 100644 --- a/packages/polaris-viz/src/components/Legend/components/LegendItem/index.ts +++ b/packages/polaris-viz/src/components/Legend/components/LegendItem/index.ts @@ -1 +1,2 @@ export {LegendItem} from './LegendItem'; +export type {LegendItemDimension} from './LegendItem'; diff --git a/packages/polaris-viz/src/components/Legend/components/LegendItem/test/LegendItem.test.tsx b/packages/polaris-viz/src/components/Legend/components/LegendItem/test/LegendItem.test.tsx index a8875635d..192a575f3 100644 --- a/packages/polaris-viz/src/components/Legend/components/LegendItem/test/LegendItem.test.tsx +++ b/packages/polaris-viz/src/components/Legend/components/LegendItem/test/LegendItem.test.tsx @@ -1,7 +1,11 @@ import {mount} from '@shopify/react-testing'; import type {LegendItemProps} from '../LegendItem'; -import {LegendItem} from '../LegendItem'; +import { + LegendItem, + MINIMUM_LEGEND_ITEM_WIDTH, + MINIMUM_LEGEND_ITEM_WITH_VALUE_WIDTH, +} from '../LegendItem'; const mockProps: LegendItemProps = { activeIndex: 2, @@ -9,9 +13,14 @@ const mockProps: LegendItemProps = { index: 0, name: 'Legend Name', color: 'red', + onDimensionChange: jest.fn(), }; describe('', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders a button', () => { const item = mount(); @@ -68,4 +77,86 @@ describe('', () => { expect(button?.props?.style?.opacity).toStrictEqual(0.3); }); }); + + describe('onDimensionChange', () => { + it('calls onDimensionChange if passed in', () => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + () => + ({ + width: 50, + height: 50, + } as DOMRect), + ); + + const onDimensionChangeSpy = jest.fn(); + mount( + , + ); + + expect(onDimensionChangeSpy).toHaveBeenCalledWith({ + width: 50, + height: 50, + }); + }); + }); + + describe('max and min width', () => { + it('sets a maxWidth if truncate is true', () => { + const item = mount(); + + expect(item.find('button')).toHaveReactProps({ + style: expect.objectContaining({ + maxWidth: MINIMUM_LEGEND_ITEM_WIDTH, + }), + }); + }); + + it('sets a maxWidth for items with values if truncate is true', () => { + const item = mount( + , + ); + + expect(item.find('button')).toHaveReactProps({ + style: expect.objectContaining({ + maxWidth: MINIMUM_LEGEND_ITEM_WITH_VALUE_WIDTH, + }), + }); + }); + + it('does not set a minWidth if the width is smaller than MINIMUM_LEGEND_ITEM_WIDTH', () => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + () => + ({ + width: MINIMUM_LEGEND_ITEM_WIDTH - 1, + height: 0, + } as DOMRect), + ); + + const item = mount(); + + expect(item.find('button')).toHaveReactProps({ + style: expect.objectContaining({ + minWidth: undefined, + }), + }); + }); + + it('sets a minWidth if the item width is greater than MINIMUM_LEGEND_ITEM_WIDTH', () => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + () => + ({ + width: MINIMUM_LEGEND_ITEM_WIDTH + 1, + height: 0, + } as DOMRect), + ); + + const item = mount(); + + expect(item.find('button')).toHaveReactProps({ + style: expect.objectContaining({ + minWidth: MINIMUM_LEGEND_ITEM_WIDTH, + }), + }); + }); + }); }); diff --git a/packages/polaris-viz/src/components/Legend/components/index.ts b/packages/polaris-viz/src/components/Legend/components/index.ts index b4b0902d6..57ab71165 100644 --- a/packages/polaris-viz/src/components/Legend/components/index.ts +++ b/packages/polaris-viz/src/components/Legend/components/index.ts @@ -1 +1,2 @@ export {LegendItem} from './LegendItem'; +export type {LegendItemDimension} from './LegendItem'; diff --git a/packages/polaris-viz/src/components/Legend/index.ts b/packages/polaris-viz/src/components/Legend/index.ts index 3f893d8b4..2d19ddf9c 100644 --- a/packages/polaris-viz/src/components/Legend/index.ts +++ b/packages/polaris-viz/src/components/Legend/index.ts @@ -2,3 +2,4 @@ export {Legend} from './Legend'; export {LegendItem} from './components'; export type {LegendProps} from './Legend'; export {estimateLegendItemWidth} from './utilities/estimateLegendItemWidth'; +export type {LegendItemDimension} from './components'; diff --git a/packages/polaris-viz/src/components/Legend/tests/Legend.test.tsx b/packages/polaris-viz/src/components/Legend/tests/Legend.test.tsx index fdf3c95e6..de04aca77 100644 --- a/packages/polaris-viz/src/components/Legend/tests/Legend.test.tsx +++ b/packages/polaris-viz/src/components/Legend/tests/Legend.test.tsx @@ -1,8 +1,10 @@ +import {createRef} from 'react'; import {mount} from '@shopify/react-testing'; import type {LegendProps} from '../Legend'; import {Legend} from '../Legend'; import {LegendItem} from '../../Legend/components'; +import type {LegendItemDimension} from '../../Legend/components'; const mockProps: LegendProps = { data: [ @@ -17,4 +19,26 @@ describe('', () => { expect(component).toContainReactComponentTimes(LegendItem, 2); }); + + it('adds the indexOffset to the index if provided', () => { + const component = mount(); + const legendItems = component.findAll(LegendItem); + expect(legendItems[0]).toHaveReactProps({ + index: 3, + }); + expect(legendItems[1]).toHaveReactProps({ + index: 4, + }); + }); + + it('updates the item dimensions', () => { + const ref = createRef(); + ref.current = []; + + const component = mount(); + const newDimensions = {width: 50, height: 50}; + + component.find(LegendItem)?.trigger('onDimensionChange', newDimensions); + expect(ref.current[0]).toStrictEqual(newDimensions); + }); }); diff --git a/packages/polaris-viz/src/components/LegendContainer/LegendContainer.scss b/packages/polaris-viz/src/components/LegendContainer/LegendContainer.scss index ea329e302..c66ae2ab6 100644 --- a/packages/polaris-viz/src/components/LegendContainer/LegendContainer.scss +++ b/packages/polaris-viz/src/components/LegendContainer/LegendContainer.scss @@ -1,5 +1,4 @@ .Container { display: flex; gap: 10px; - flex-wrap: wrap; } diff --git a/packages/polaris-viz/src/components/LegendContainer/LegendContainer.tsx b/packages/polaris-viz/src/components/LegendContainer/LegendContainer.tsx index 628a41383..e574a7244 100644 --- a/packages/polaris-viz/src/components/LegendContainer/LegendContainer.tsx +++ b/packages/polaris-viz/src/components/LegendContainer/LegendContainer.tsx @@ -1,5 +1,5 @@ import type {CSSProperties, Dispatch, SetStateAction} from 'react'; -import {useEffect, useRef, useState} from 'react'; +import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; import isEqual from 'fast-deep-equal'; import { getColorVisionEventAttrs, @@ -9,7 +9,11 @@ import { useChartContext, useTheme, } from '@shopify/polaris-viz-core'; -import type {Direction, Dimensions} from '@shopify/polaris-viz-core'; +import type { + Direction, + Dimensions, + BoundingRect, +} from '@shopify/polaris-viz-core'; import {DEFAULT_LEGEND_HEIGHT, DEFAULT_LEGEND_WIDTH} from '../../constants'; import {useResizeObserver, useWatchColorVisionEvents} from '../../hooks'; @@ -17,11 +21,15 @@ import {Legend} from '../Legend'; import type { LegendData, LegendPosition, + RenderHiddenLegendLabel, RenderLegendContent, } from '../../types'; import {classNames} from '../../utilities'; import style from './LegendContainer.scss'; +import {HiddenLegendTooltip} from './components/HiddenLegendTooltip'; + +const LEGEND_GAP = 10; export interface LegendContainerProps { colorVisionType: string; @@ -32,17 +40,27 @@ export interface LegendContainerProps { position?: LegendPosition; maxWidth?: number; renderLegendContent?: RenderLegendContent; + /* If enabled, hides overflowing legend items with "+ n more" */ + enableHideOverflow?: boolean; + /* Width is required if enableHideOverflow is true */ + width?: number; + renderHiddenLegendLabel?: RenderHiddenLegendLabel; + dimensions?: BoundingRect; } export function LegendContainer({ colorVisionType, - data, + data: allData, onDimensionChange, direction = 'horizontal', fullWidth = false, position = 'bottom-right', maxWidth, renderLegendContent, + width = 0, + enableHideOverflow = false, + renderHiddenLegendLabel = (count) => `+${count} more`, + dimensions, }: LegendContainerProps) { const selectedTheme = useTheme(); const {setRef, entry} = useResizeObserver(); @@ -57,6 +75,43 @@ export function LegendContainer({ const {horizontalMargin} = selectedTheme.grid; const leftMargin = isPositionLeft ? 0 : horizontalMargin; + const legendItemDimensions = useRef([{width: 0, height: 0}]); + const [activatorWidth, setActivatorWidth] = useState(0); + + const {displayedData, hiddenData} = useMemo(() => { + if (!enableHideOverflow || direction === 'vertical') { + return {displayedData: allData, hiddenData: []}; + } + + let lastVisibleIndex = allData.length; + const containerWidth = + width - leftMargin - horizontalMargin - activatorWidth; + + legendItemDimensions.current.reduce((totalWidth, card, index) => { + if (totalWidth + card.width + index * LEGEND_GAP > containerWidth) { + lastVisibleIndex = index; + } else { + return totalWidth + card.width; + } + }, lastVisibleIndex); + + return { + displayedData: allData.slice(0, lastVisibleIndex || 1), + hiddenData: allData.slice(lastVisibleIndex || 1, allData.length), + }; + }, [ + allData, + width, + leftMargin, + horizontalMargin, + activatorWidth, + enableHideOverflow, + direction, + ]); + + const hasHiddenData = + enableHideOverflow && displayedData.length < allData.length; + const styleMap: {[key: string]: CSSProperties} = { horizontal: { justifyContent: 'flex-end', @@ -64,6 +119,7 @@ export function LegendContainer({ ? `0 ${horizontalMargin}px ${LEGENDS_BOTTOM_MARGIN}px ${leftMargin}px` : `${LEGENDS_TOP_MARGIN}px ${horizontalMargin}px 0 ${leftMargin}px`, flexDirection: 'row', + flexWrap: enableHideOverflow ? 'nowrap' : 'wrap', }, vertical: { alignItems: 'flex-start', @@ -125,12 +181,30 @@ export function LegendContainer({ style={{...styleMap[direction], ...shouldCenterTiles(position)}} > {renderLegendContent?.(colorVisionInteractionMethods) ?? ( - + + + {hasHiddenData && ( + + )} + )} ); diff --git a/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.scss b/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.scss new file mode 100644 index 000000000..75f11800c --- /dev/null +++ b/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.scss @@ -0,0 +1,18 @@ +.MoreText { + display: flex; + white-space: nowrap; + align-items: center; + background: none; + border: none; + border-radius: 2px; +} + +.Tooltip { + position: absolute; + display: flex; + flex-direction: column; + padding: 4px; + backdrop-filter: blur(5px); + border-radius: 5px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2), 0 2px 10px rgba(0, 0, 0, 0.1); +} diff --git a/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.tsx b/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.tsx new file mode 100644 index 000000000..a5fb21308 --- /dev/null +++ b/packages/polaris-viz/src/components/LegendContainer/components/HiddenLegendTooltip.tsx @@ -0,0 +1,176 @@ +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type {ReactNode} from 'react'; +import {createPortal} from 'react-dom'; +import { + changeColorOpacity, + useChartContext, + useTheme, +} from '@shopify/polaris-viz-core'; +import type {BoundingRect} from '@shopify/polaris-viz-core'; + +import type {LegendData} from '../../../types'; +import {TOOLTIP_BG_OPACITY} from '../../../constants'; +import {useBrowserCheck} from '../../../hooks/useBrowserCheck'; +import {useRootContainer} from '../../../hooks/useRootContainer'; +import {useColorVisionEvents} from '../../../hooks/ColorVisionA11y'; +import {TOOLTIP_MARGIN} from '../../TooltipWrapper'; +import {Legend} from '../../Legend'; + +import style from './HiddenLegendTooltip.scss'; + +interface Props { + activeIndex: number; + colorVisionType: string; + data: LegendData[]; + label: ReactNode; + setActivatorWidth: (width: number) => void; + theme?: string; + lastVisibleIndex?: number; + dimensions?: BoundingRect; +} + +export const LEGEND_TOOLTIP_ID = 'legend-toolip'; + +export function HiddenLegendTooltip({ + activeIndex, + colorVisionType, + data, + theme, + label, + lastVisibleIndex = 0, + setActivatorWidth, + dimensions, +}: Props) { + const selectedTheme = useTheme(); + const {isFirefox} = useBrowserCheck(); + const {id} = useChartContext(); + const tooltipId = `${LEGEND_TOOLTIP_ID}_${id}`; + const container = useRootContainer(tooltipId); + const tooltipRef = useRef(null); + const activatorRef = useRef(null); + useColorVisionEvents({enabled: true, root: LEGEND_TOOLTIP_ID, dimensions}); + + const defaultPosition = useMemo( + () => ({ + top: 0, + left: 0, + }), + [], + ); + + const [position, setPosition] = + useState<{top: number; left: number}>(defaultPosition); + const [active, setActive] = useState(false); + + useEffect(() => { + if (activatorRef.current == null) { + return; + } + const activator = activatorRef.current.getBoundingClientRect(); + setActivatorWidth(activator.width); + }, [setActivatorWidth]); + + const getTooltipPosition = useCallback(() => { + if (tooltipRef.current == null || activatorRef.current == null) { + return; + } + + setActive(true); + + const activator = activatorRef.current.getBoundingClientRect(); + const tooltip = tooltipRef.current.getBoundingClientRect(); + + const xPosition = activator.x + window.scrollX; + const yPosition = activator.y + window.scrollY + activator.height; + + function getXPosition() { + const goesPastRightOfWindow = + xPosition + tooltip.width + TOOLTIP_MARGIN > window.innerWidth; + + if (goesPastRightOfWindow) { + return xPosition - tooltip.width + activator.width; + } + return xPosition; + } + + function getYPosition() { + const goesPastBottomOfWindow = + yPosition + tooltip.height + TOOLTIP_MARGIN >= + window.innerHeight + window.scrollY; + if (goesPastBottomOfWindow) { + return yPosition - tooltip.height - activator.height; + } + + return yPosition; + } + + setPosition({ + top: getYPosition(), + left: getXPosition(), + }); + }, [setPosition]); + + const handleMouseLeave = useCallback( + (event) => { + if (event?.relatedTarget.id !== tooltipId) { + setActive(false); + setPosition(defaultPosition); + } + }, + [setActive, setPosition, defaultPosition, tooltipId], + ); + + return ( + + + + {createPortal( +
+ +
, + container, + )} +
+ ); +} diff --git a/packages/polaris-viz/src/components/LegendContainer/components/tests/HiddenLegendTooltip.test.tsx b/packages/polaris-viz/src/components/LegendContainer/components/tests/HiddenLegendTooltip.test.tsx new file mode 100644 index 000000000..3035afec8 --- /dev/null +++ b/packages/polaris-viz/src/components/LegendContainer/components/tests/HiddenLegendTooltip.test.tsx @@ -0,0 +1,180 @@ +import {mount} from '@shopify/react-testing'; + +import {HiddenLegendTooltip} from '../HiddenLegendTooltip'; +import {Legend} from '../../../Legend'; + +const mockProps = { + activeIndex: 0, + colorVisionType: 'someType', + label: '+3 more', + data: [ + {name: 'Legend One', color: 'red'}, + {name: 'Legend Two', color: 'blue'}, + {name: 'Legend Three', color: 'yellow'}, + ], + setActivatorWidth: jest.fn(), +}; + +describe('', () => { + it('renders a legend with the hidden items', () => { + const component = mount(); + + expect(component).toContainReactComponent(Legend, { + data: mockProps.data, + }); + }); + + it('calls setActivatorWidth', () => { + const mockSetActivatorWidth = jest.fn(); + mount( + , + ); + + expect(mockSetActivatorWidth).toHaveBeenCalled(); + }); + + it('sets visible styles on mouse enter', () => { + const component = mount(); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + visibility: 'hidden', + zIndex: -100000, + }), + }); + + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + visibility: 'visible', + zIndex: 1, + }), + }); + }); + + it('sets hidden styles on mouse leave', () => { + const component = mount(); + + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + visibility: 'visible', + zIndex: 1, + }), + }); + + component.find('button')?.trigger('onMouseLeave'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + visibility: 'hidden', + zIndex: -100000, + }), + }); + }); + + describe('tooltip position', () => { + beforeEach(() => { + jest + .spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect') + .mockImplementation( + () => + ({ + x: buttonX, + y: buttonY, + width: buttonWidth, + height: buttonHeight, + } as DOMRect), + ); + + jest + .spyOn(HTMLDivElement.prototype, 'getBoundingClientRect') + .mockImplementation( + () => + ({ + width: tooltipWidth, + height: tooltipHeight, + } as DOMRect), + ); + }); + + it('sets the position to top right', () => { + window.innerWidth = 1000; + window.innerHeight = 100; + + const component = mount(); + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + top: expectedPositions.top, + left: expectedPositions.right, + }), + }); + }); + + it('sets the position to top left', () => { + window.innerWidth = 90; + window.innerHeight = 100; + + const component = mount(); + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + top: expectedPositions.top, + left: expectedPositions.left, + }), + }); + }); + + it('sets the position to bottom right', () => { + window.innerWidth = 1000; + window.innerHeight = 1000; + + const component = mount(); + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + top: expectedPositions.bottom, + left: expectedPositions.right, + }), + }); + }); + + it('sets the position to bottom left', () => { + window.innerWidth = 90; + window.innerHeight = 1000; + + const component = mount(); + component.find('button')?.trigger('onMouseEnter'); + + expect(component.find('div')).toHaveReactProps({ + style: expect.objectContaining({ + top: expectedPositions.bottom, + left: expectedPositions.left, + }), + }); + }); + }); +}); + +const tooltipWidth = 75; +const tooltipHeight = 100; +const buttonWidth = 50; +const buttonHeight = 20; +const buttonX = 0; +const buttonY = 0; + +const expectedPositions = { + top: buttonY - tooltipHeight, + left: buttonX - tooltipWidth + buttonWidth, + bottom: buttonY + buttonHeight, + right: buttonX, +}; diff --git a/packages/polaris-viz/src/components/LegendContainer/tests/LegendsContainer.test.tsx b/packages/polaris-viz/src/components/LegendContainer/tests/LegendsContainer.test.tsx index 30aa386e2..3eeeff31a 100644 --- a/packages/polaris-viz/src/components/LegendContainer/tests/LegendsContainer.test.tsx +++ b/packages/polaris-viz/src/components/LegendContainer/tests/LegendsContainer.test.tsx @@ -2,16 +2,22 @@ import {mount} from '@shopify/react-testing'; import type {LegendContainerProps} from '../LegendContainer'; import {LegendContainer} from '../LegendContainer'; -import {Legend} from '../../Legend'; +import {Legend, LegendItem} from '../../Legend'; +import {HiddenLegendTooltip} from '../components/HiddenLegendTooltip'; + +const WIDTH_WITH_OVERFLOW = 0; +const WIDTH_WITHOUT_OVERFLOW = 100; const mockProps: LegendContainerProps = { colorVisionType: 'someType', data: [ {name: 'Legend One', color: 'red'}, {name: 'Legend Two', color: 'blue'}, + {name: 'Legend Three', color: 'yellow'}, ], onDimensionChange: jest.fn(), theme: 'Default', + width: WIDTH_WITHOUT_OVERFLOW, }; jest.mock('../../../hooks/useResizeObserver', () => { @@ -34,10 +40,6 @@ describe('', () => { ); - beforeEach(() => { - jest.resetAllMocks(); - }); - it('renders by default', () => { const component = mount(); @@ -67,4 +69,101 @@ describe('', () => { expect(mockRenderLegendContent).toHaveBeenCalledTimes(1); }); + + describe('enableHideOverflow', () => { + it('does not hide items if false', () => { + const component = mount( + , + ); + + expect(component).not.toContainReactComponent(HiddenLegendTooltip); + }); + + it('sets flexWrap to nowrap if true', () => { + const component = mount( + , + ); + + expect(component.find('div', {role: 'list'})).toHaveReactProps({ + style: expect.objectContaining({ + flexWrap: 'nowrap', + }), + }); + }); + + it('sets flexWrap to nowrap if false', () => { + const component = mount( + , + ); + + expect(component.find('div', {role: 'list'})).toHaveReactProps({ + style: expect.objectContaining({ + flexWrap: 'wrap', + }), + }); + }); + + it('renders HiddenLegendTooltip if there is hidden data', () => { + const component = mount( + , + ); + + expect(component).toContainReactComponent(HiddenLegendTooltip); + }); + + it('does not render HiddenLegendTooltip if there is no hidden data', () => { + const component = mount( + , + ); + + expect(component).not.toContainReactComponent(HiddenLegendTooltip); + }); + }); + + describe('renderHiddenLegendLabel', () => { + it('renders the default label if not provided', () => { + const component = mount( + , + ); + + expect(component).toContainReactComponent(HiddenLegendTooltip, { + label: `+${mockProps.data.length - 1} more`, + }); + }); + + it('renders a custom label if provided', () => { + const component = mount( + `Custom legend label ${x}`} + />, + ); + + expect(component).toContainReactComponent(HiddenLegendTooltip, { + label: `Custom legend label ${mockProps.data.length - 1}`, + }); + }); + }); }); diff --git a/packages/polaris-viz/src/components/LineChart/Chart.tsx b/packages/polaris-viz/src/components/LineChart/Chart.tsx index 2b2e6dd2f..7fe47a5cb 100644 --- a/packages/polaris-viz/src/components/LineChart/Chart.tsx +++ b/packages/polaris-viz/src/components/LineChart/Chart.tsx @@ -29,6 +29,7 @@ import { import type { AnnotationLookupTable, LineChartSlotProps, + RenderHiddenLegendLabel, RenderLegendContent, RenderTooltipContentData, } from '../../types'; @@ -76,6 +77,7 @@ export interface ChartProps { dimensions?: BoundingRect; emptyStateText?: string; renderLegendContent?: RenderLegendContent; + renderHiddenLegendLabel?: RenderHiddenLegendLabel; slots?: { chart?: (props: LineChartSlotProps) => JSX.Element; }; @@ -89,13 +91,17 @@ export function Chart({ dimensions, renderLegendContent, renderTooltipContent, + renderHiddenLegendLabel, showLegend = true, slots, theme = DEFAULT_THEME_NAME, xAxisOptions, yAxisOptions, }: ChartProps) { - useColorVisionEvents(data.length > 1); + useColorVisionEvents({ + enabled: data.length > 1, + dimensions, + }); const selectedTheme = useTheme(theme); const {isPerformanceImpacted} = useChartContext(); @@ -424,6 +430,10 @@ export function Chart({ data={legend} onDimensionChange={setLegendDimensions} renderLegendContent={renderLegendContent} + renderHiddenLegendLabel={renderHiddenLegendLabel} + width={width} + dimensions={dimensions} + enableHideOverflow /> )} diff --git a/packages/polaris-viz/src/components/LineChart/LineChart.tsx b/packages/polaris-viz/src/components/LineChart/LineChart.tsx index fbcedb314..4fd15c22b 100644 --- a/packages/polaris-viz/src/components/LineChart/LineChart.tsx +++ b/packages/polaris-viz/src/components/LineChart/LineChart.tsx @@ -38,6 +38,7 @@ export type LineChartProps = { errorText?: string; emptyStateText?: string; renderLegendContent?: RenderLegendContent; + renderHiddenLegendLabel?: (count: number) => string; showLegend?: boolean; skipLinkText?: string; tooltipOptions?: TooltipOptions; @@ -60,6 +61,7 @@ export function LineChart(props: LineChartProps) { isAnimated, onError, renderLegendContent, + renderHiddenLegendLabel, showLegend = true, skipLinkText, state, @@ -111,6 +113,7 @@ export function LineChart(props: LineChartProps) { emptyStateText={emptyStateText} renderLegendContent={renderLegendContent} renderTooltipContent={renderTooltip} + renderHiddenLegendLabel={renderHiddenLegendLabel} showLegend={showLegend} theme={theme} xAxisOptions={xAxisOptionsWithDefaults} diff --git a/packages/polaris-viz/src/components/LineChart/stories/SeriesColors.stories.tsx b/packages/polaris-viz/src/components/LineChart/stories/SeriesColors.stories.tsx index bfa5b5ab0..3748ef56c 100644 --- a/packages/polaris-viz/src/components/LineChart/stories/SeriesColors.stories.tsx +++ b/packages/polaris-viz/src/components/LineChart/stories/SeriesColors.stories.tsx @@ -10,11 +10,11 @@ import {Template} from './data'; export const SeriesColorsUpToEight: Story = Template.bind({}); SeriesColorsUpToEight.args = { - data: generateMultipleSeries(4, 'dates'), + data: generateMultipleSeries(8, 'dates'), }; export const SeriesColorsUpToSixteen: Story = Template.bind({}); SeriesColorsUpToSixteen.args = { - data: generateMultipleSeries(14, 'dates'), + data: generateMultipleSeries(16, 'dates'), }; diff --git a/packages/polaris-viz/src/components/LineChart/stories/playground/Playground.stories.tsx b/packages/polaris-viz/src/components/LineChart/stories/playground/Playground.stories.tsx index 79d4737dd..63b29119b 100644 --- a/packages/polaris-viz/src/components/LineChart/stories/playground/Playground.stories.tsx +++ b/packages/polaris-viz/src/components/LineChart/stories/playground/Playground.stories.tsx @@ -1,7 +1,7 @@ import type {Story} from '@storybook/react'; import {LineChart, LineChartProps} from '../../LineChart'; -import {randomNumber} from '../../../Docs/utilities'; +import {generateDataSet, randomNumber} from '../../../Docs/utilities'; import { formatLinearXAxisLabel, formatLinearYAxisLabel, @@ -1023,3 +1023,47 @@ LinearComparisonTooltip.args = { }, ], }; + +export const LongLegend: Story = Template.bind({}); + +LongLegend.args = { + data: [ + { + name: 'Garlic & Herb Biltong Slab - Family Size Super Pack', + data: generateDataSet(10, 'dates'), + }, + { + name: 'Chili Biltong Slab 8oz', + data: generateDataSet(10, 'dates'), + }, + { + name: 'Sale', + data: generateDataSet(10, 'dates'), + }, + { + name: '1', + data: generateDataSet(10, 'dates'), + }, + { + name: 'Smokehouse Biltong', + data: generateDataSet(10, 'dates'), + }, + { + name: 'Traditional Biltong Slab 8oz', + data: generateDataSet(10, 'dates'), + }, + { + name: '2', + data: generateDataSet(10, 'dates'), + }, + { + name: 'A Very Very Very Very Very Long Titled Biltong', + data: generateDataSet(10, 'dates'), + }, + + { + name: '3', + data: generateDataSet(10, 'dates'), + }, + ], +}; diff --git a/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx b/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx index a1442a509..71bfdf81c 100644 --- a/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/SimpleBarChart/Chart.tsx @@ -52,7 +52,7 @@ export function Chart({ xAxisOptions, yAxisOptions, }: ChartProps) { - useColorVisionEvents(data.length > 1); + useColorVisionEvents({enabled: data.length > 1}); 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 d40e801dd..5f3b0a066 100644 --- a/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx +++ b/packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx @@ -93,7 +93,7 @@ export function Chart({ theme, yAxisOptions, }: Props) { - useColorVisionEvents(data.length > 1); + useColorVisionEvents({enabled: data.length > 1}); const selectedTheme = useTheme(theme); const seriesColors = useThemeSeriesColors(data, selectedTheme); @@ -397,6 +397,9 @@ export function Chart({ data={legend} onDimensionChange={setLegendDimensions} renderLegendContent={renderLegendContent} + width={width} + enableHideOverflow + dimensions={chartBounds} /> )} diff --git a/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx b/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx index d69abee9a..ee585ca2e 100644 --- a/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx +++ b/packages/polaris-viz/src/components/VerticalBarChart/Chart.tsx @@ -82,7 +82,7 @@ export function Chart({ xAxisOptions, yAxisOptions, }: Props) { - useColorVisionEvents(data.length > 1); + useColorVisionEvents({enabled: data.length > 1, dimensions}); const selectedTheme = useTheme(); const {characterWidths} = useChartContext(); @@ -329,6 +329,9 @@ export function Chart({ data={legend} onDimensionChange={setLegendDimensions} renderLegendContent={renderLegendContent} + width={width} + enableHideOverflow + dimensions={dimensions} /> )} diff --git a/packages/polaris-viz/src/hooks/ColorVisionA11y/useColorVisionEvents.ts b/packages/polaris-viz/src/hooks/ColorVisionA11y/useColorVisionEvents.ts index de0bdc67c..3ddc434a4 100644 --- a/packages/polaris-viz/src/hooks/ColorVisionA11y/useColorVisionEvents.ts +++ b/packages/polaris-viz/src/hooks/ColorVisionA11y/useColorVisionEvents.ts @@ -1,11 +1,20 @@ import {useEffect} from 'react'; import {COLOR_VISION_EVENT, useChartContext} from '@shopify/polaris-viz-core'; +import type {BoundingRect} from '@shopify/polaris-viz-core'; import {useExternalHideEvents} from '../ExternalEvents'; import {getDataSetItem, getEventName} from './utilities'; -export function useColorVisionEvents(enabled = true) { +export interface Props { + enabled?: boolean; + dimensions?: BoundingRect; + root?: string; +} + +export function useColorVisionEvents(props?: Partial) { + const {enabled = true, dimensions, root = 'chart'} = props || {}; + const {id} = useChartContext(); const {hiddenIndexes} = useExternalHideEvents(); @@ -15,7 +24,7 @@ export function useColorVisionEvents(enabled = true) { } const items = document.querySelectorAll( - `#chart_${id} [${COLOR_VISION_EVENT.dataAttribute}-watch="true"]`, + `#${root}_${id} [${COLOR_VISION_EVENT.dataAttribute}-watch="true"]`, ); function onMouseEnter(event: MouseEvent) { @@ -70,5 +79,5 @@ export function useColorVisionEvents(enabled = true) { item.removeEventListener('blur', onMouseLeave); }); }; - }, [id, enabled, hiddenIndexes]); + }, [id, enabled, hiddenIndexes, dimensions, root]); } diff --git a/packages/polaris-viz/src/types.ts b/packages/polaris-viz/src/types.ts index ce21f0efe..26d69292e 100644 --- a/packages/polaris-viz/src/types.ts +++ b/packages/polaris-viz/src/types.ts @@ -208,6 +208,8 @@ export type RenderLegendContent = ( colorVisionInteractionMethods: ColorVisionInteractionMethods, ) => ReactNode; +export type RenderHiddenLegendLabel = (hiddenItemsCount: number) => ReactNode; + export type SortedBarChartData = (number | null)[][]; export interface InnerValueContents {