diff --git a/.changeset/charts-tooltip-visibility.md b/.changeset/charts-tooltip-visibility.md new file mode 100644 index 0000000000..4551915571 --- /dev/null +++ b/.changeset/charts-tooltip-visibility.md @@ -0,0 +1,5 @@ +--- +'@lg-charts/core': minor +--- + +[LG-4580](https://jira.mongodb.org/browse/LG-4580): implement tooltip pinning and visibility management diff --git a/charts/core/package.json b/charts/core/package.json index 09fce92a49..9bd899163a 100644 --- a/charts/core/package.json +++ b/charts/core/package.json @@ -19,6 +19,8 @@ "@dnd-kit/utilities": "^3.2.2", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", + "@leafygreen-ui/icon": "workspace:^", + "@leafygreen-ui/icon-button": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", @@ -34,9 +36,7 @@ }, "devDependencies": { "@faker-js/faker": "8.0.2", - "@leafygreen-ui/icon": "workspace:^", - "@lg-tools/build": "workspace:^", - "@types/lodash.debounce": "^4.0.9" + "@lg-tools/build": "workspace:^" }, "repository": { "type": "git", diff --git a/charts/core/src/Chart.stories.tsx b/charts/core/src/Chart.stories.tsx index 095f43fa0c..77aa231e15 100644 --- a/charts/core/src/Chart.stories.tsx +++ b/charts/core/src/Chart.stories.tsx @@ -1072,7 +1072,7 @@ export const WithWarningEventMarkerLine: StoryObj<{}> = { }, }; -export const WithZoomAndTooltip: StoryObj<{}> = { +export const WithZoom: StoryObj<{}> = { render: () => { return ( = { }, }; -export const WithZoom: StoryObj<{}> = { +export const WithXAxisZoom: StoryObj<{}> = { render: () => { return ( {lineData.map(({ name, data }) => ( @@ -1106,12 +1105,12 @@ export const WithZoom: StoryObj<{}> = { }, }; -export const WithXAxisZoom: StoryObj<{}> = { +export const WithYAxisZoom: StoryObj<{}> = { render: () => { return ( {lineData.map(({ name, data }) => ( @@ -1122,20 +1121,27 @@ export const WithXAxisZoom: StoryObj<{}> = { }, }; -export const WithYAxisZoom: StoryObj<{}> = { +export const WithZoomAndTooltip: StoryObj<{}> = { render: () => { return ( + {lineData.map(({ name, data }) => ( ))} ); }, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, }; export const SyncedByGroupID: StoryObj<{}> = { diff --git a/charts/core/src/Chart/Chart.tsx b/charts/core/src/Chart/Chart.tsx index 9701aa2362..38b1ba146e 100644 --- a/charts/core/src/Chart/Chart.tsx +++ b/charts/core/src/Chart/Chart.tsx @@ -38,6 +38,7 @@ export function Chart({ className, state = ChartStates.Unset, dragId = '', + id: idProp, ...rest }: ChartProps) { const { theme } = useDarkMode(darkModeProp); @@ -46,6 +47,7 @@ export function Chart({ onChartReady, zoomSelect, onZoomSelect, + chartId: idProp, groupId, state, }); @@ -102,6 +104,7 @@ export function Chart({ ref={chart.ref} className={chartStyles} data-testid="lg-charts-core-chart-echart" + id={chart.id} {...rest} /> diff --git a/charts/core/src/Chart/hooks/useChart.spec.ts b/charts/core/src/Chart/hooks/useChart.spec.ts index d7e3231305..8683359050 100644 --- a/charts/core/src/Chart/hooks/useChart.spec.ts +++ b/charts/core/src/Chart/hooks/useChart.spec.ts @@ -105,7 +105,9 @@ describe('@lg-echarts/core/hooks/useChart', () => { // Simulate zoom select event const zoomEventResponse = { start: 0, end: 100 }; - const zoomSelectHandler = on.mock.calls[0][1]; + const zoomSelectHandler = on.mock.calls.find( + ([action, _]) => action === EChartEventsMock.ZoomSelect, + )[1]; act(() => { zoomSelectHandler(zoomEventResponse); }); @@ -125,11 +127,18 @@ describe('@lg-echarts/core/hooks/useChart', () => { (useEchart as jest.Mock).mockReturnValue(mockEchartInstance); - const { result } = renderHook(() => useChart({ theme: 'dark' })); + const { result } = renderHook(() => + useChart({ chartId: 'test-chart-id', theme: 'dark' }), + ); expect(result.current).toEqual({ ...mockEchartInstance, + id: 'test-chart-id', + isChartHovered: true, ref: expect.any(Function), + setTooltipMounted: expect.any(Function), + state: undefined, + tooltipPinned: false, }); }); diff --git a/charts/core/src/Chart/hooks/useChart.ts b/charts/core/src/Chart/hooks/useChart.ts index 11cb8fb75c..11abaeb568 100644 --- a/charts/core/src/Chart/hooks/useChart.ts +++ b/charts/core/src/Chart/hooks/useChart.ts @@ -1,15 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; + import { useEchart } from '../../Echart'; import { EChartEvents } from '../../Echart'; import { getDefaultChartOptions } from '../config'; import type { ChartHookProps, ChartInstance } from './useChart.types'; +import { useTooltipVisibility } from './useTooltipVisibility'; export function useChart({ onChartReady = () => {}, zoomSelect, onZoomSelect, + chartId, groupId, theme, state, @@ -24,6 +28,8 @@ export function useChart({ */ const [container, setContainer] = useState(null); + const id = useIdAllocator({ id: chartId, prefix: 'lg-chart' }); + const echart = useEchart({ container, initialOptions, @@ -33,7 +39,6 @@ export function useChart({ const { addToGroup, enableZoom, - hideTooltip, off, on, ready, @@ -49,8 +54,16 @@ export function useChart({ onChartReady(); }, [ready, onChartReady]); + const { isChartHovered, setTooltipMounted, tooltipPinned } = + useTooltipVisibility({ + chartId: id, + container, + echart, + groupId, + }); + useEffect(() => { - if (!ready || !groupId) { + if (!ready || !groupId || tooltipPinned) { return; } @@ -59,7 +72,7 @@ export function useChart({ return () => { removeFromGroup(); }; - }, [ready, groupId, addToGroup, removeFromGroup]); + }, [ready, groupId, addToGroup, removeFromGroup, tooltipPinned]); // SETUP AND ENABLE ZOOM useEffect(() => { @@ -96,27 +109,6 @@ export function useChart({ }); }, [ready, onZoomSelect, on]); - // We want to hide the tooltip when it's hovered over any `EventMarkerPoint` - useEffect(() => { - if (!ready) { - return; - } - - on('mouseover', e => { - if (e.componentType === 'markPoint') { - hideTooltip(); - on('mousemove', hideTooltip); - } - }); - - // Stop hiding once the mouse leaves the `EventMarkerPoint` - on('mouseout', e => { - if (e.componentType === 'markPoint') { - off('mousemove', hideTooltip); - } - }); - }, [echart, hideTooltip, off, on, ready]); - const initialRenderRef = useRef(true); const handleResize = useCallback(() => { @@ -170,7 +162,11 @@ export function useChart({ return { ...echart, + id, + isChartHovered, ref: setContainer, + setTooltipMounted, state, + tooltipPinned, }; } diff --git a/charts/core/src/Chart/hooks/useChart.types.ts b/charts/core/src/Chart/hooks/useChart.types.ts index 2808f67d2e..58678e00df 100644 --- a/charts/core/src/Chart/hooks/useChart.types.ts +++ b/charts/core/src/Chart/hooks/useChart.types.ts @@ -5,6 +5,8 @@ import { Theme } from '@leafygreen-ui/lib'; import type { EChartsInstance, EChartZoomSelectionEvent } from '../../Echart'; import { ChartStates } from '../Chart.types'; +import { UseTooltipVisibilityReturnObj } from './useTooltipVisibility.types'; + export type ZoomSelect = | { xAxis: boolean; @@ -19,6 +21,11 @@ export type ZoomSelect = export interface ChartHookProps { theme: Theme; + /** + * The id of the chart. + */ + chartId?: string; + /** * Charts with the same `groupId` will have their tooltips synced across charts. */ @@ -47,6 +54,8 @@ export interface ChartHookProps { export interface ChartInstance extends EChartsInstance, - Pick { + Pick, + UseTooltipVisibilityReturnObj { + id: string; ref: RefCallback; } diff --git a/charts/core/src/Chart/hooks/useTooltipVisibility.ts b/charts/core/src/Chart/hooks/useTooltipVisibility.ts new file mode 100644 index 0000000000..eb19ab696b --- /dev/null +++ b/charts/core/src/Chart/hooks/useTooltipVisibility.ts @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { EChartEvents, EChartsInstance } from '../../Echart'; + +import { UseTooltipVisibilityReturnObj } from './useTooltipVisibility.types'; + +/** + * Hook to manage the visibility of the tooltip in the chart including pinning behavior. + */ +export const useTooltipVisibility = ({ + chartId, + container, + echart, + groupId, +}: { + chartId: string; + container: HTMLDivElement | null; + echart: EChartsInstance; + groupId?: string; +}): UseTooltipVisibilityReturnObj => { + // if groupId is not provided, this state will always be true + const [isChartHovered, setIsChartHovered] = useState(!groupId); + const [pinnedPosition, setPinnedPosition] = useState([0, 0]); + const [tooltipMounted, setTooltipMounted] = useState(false); + const [tooltipPinned, setTooltipPinned] = useState(false); + + const { + addToGroup, + hideTooltip, + off, + on, + ready, + removeFromGroup, + showTooltip, + } = echart; + + /** + * Event listener callback added to the chart that is called on `mouseenter` + * or `mouseleave` to set the `isChartHovered` state. + */ + const toggleChartHover = useCallback((isMouseEnter: boolean) => { + setIsChartHovered(isMouseEnter); + }, []); + + /** + * Event listener callback added to the chart that is called on mouse move + * to show the tooltip. + */ + const showTooltipOnMouseMove = useCallback( + (params: any) => { + if (!tooltipMounted || tooltipPinned) { + return; + } + + const { offsetX, offsetY } = params; + showTooltip(offsetX, offsetY); + }, + [showTooltip, tooltipMounted, tooltipPinned], + ); + + /** + * Event listener callback added to the close button in the tooltip. When called, + * it hides the tooltip and sets the `tooltipPinned` state to false. + */ + const unpinTooltip = useCallback(() => { + /** + * When the tooltip is pinned, the chart is removed from the group to prevent + * event listeners of sibling charts from triggering the tooltip of current chart. + * + * When unpinning the tooltip, the chart is added back to the group. + */ + if (groupId) { + addToGroup(groupId); + } + + setTooltipPinned(false); + + /** + * Use requestAnimationFrame to ensure that the tooltip is hidden after + * `ChartTooltip` has reacted to the `tooltipPinned` state change. + */ + requestAnimationFrame(() => { + hideTooltip(); + }); + }, [addToGroup, groupId, hideTooltip]); + + /** + * Helper method to add event listener to the close button in the tooltip. + * + * The echarts tooltip `formatter` cannot pass the `onClick` event to the button, + * so it has to be added manually. + */ + const addUnpinCallbackToCloseButton = useCallback(() => { + const btn = document.querySelector(`[data-chartid="${chartId}"]`); + + if (btn instanceof HTMLElement && !btn.dataset.bound) { + btn.addEventListener('click', unpinTooltip); + btn.dataset.bound = 'true'; // prevents duplicate listeners + } + }, [chartId, unpinTooltip]); + + /** + * Event listener callback added to the chart that is called on click to record the + * `pinnedPosition` state and set the `tooltipPinned` state to true. + * + * Separate effect is used to show tooltip and add the unpin event listener because + * the `ChartTooltip` instance must first react to the `tooltipPinned` state change. + */ + const pinTooltipOnClick = useCallback( + (params: any) => { + if (!tooltipMounted || tooltipPinned) { + return; + } + + /** + * When the tooltip is pinned, the chart is removed from the group to prevent + * event listeners of sibling charts from triggering the tooltip of current chart. + */ + if (groupId) { + removeFromGroup(); + } + + /** + * Remove the mouse move and click event listeners to prevent the tooltip from + * moving when it is pinned. User can unpin it by clicking the close button in + * the tooltip which will turn the listeners back on. + */ + off(EChartEvents.MouseMove, showTooltipOnMouseMove, { + useCanvasAsTrigger: true, + }); + off(EChartEvents.Click, pinTooltipOnClick, { useCanvasAsTrigger: true }); + + const { offsetX, offsetY } = params; + setPinnedPosition([offsetX, offsetY]); + setTooltipPinned(true); + }, + [ + tooltipMounted, + tooltipPinned, + groupId, + off, + showTooltipOnMouseMove, + removeFromGroup, + ], + ); + + /** + * Event listener callback that is called when mousing over a mark point or line. + * It hides the tooltip and disables the chart click event listener. + */ + const hideTooltipOnMouseOverMark = useCallback( + (params: any) => { + if (!tooltipMounted) { + return; + } + + if ( + params.componentType === 'markPoint' || + params.componentType === 'markLine' + ) { + hideTooltip(); + on(EChartEvents.MouseMove, hideTooltip); + off(EChartEvents.Click, pinTooltipOnClick, { + useCanvasAsTrigger: true, + }); + } + }, + [hideTooltip, off, on, pinTooltipOnClick, tooltipMounted], + ); + + /** + * Event listener callback that is called when mousing out of a mark point or line. + * It stops hiding the tooltip and re-enables the chart click event listener. + */ + const stopHideTooltipOnMouseOutMark = useCallback( + (params: any) => { + if (!tooltipMounted) { + return; + } + + if ( + params.componentType === 'markPoint' || + params.componentType === 'markLine' + ) { + off(EChartEvents.MouseMove, hideTooltip); + on(EChartEvents.Click, pinTooltipOnClick, { + useCanvasAsTrigger: true, + }); + } + }, + [hideTooltip, off, on, pinTooltipOnClick, tooltipMounted], + ); + + /** + * Effect to add event listeners to the chart container to toggle the `isChartHovered` + * state on mouse enter and leave when the chart is grouped. + * + * When charts are grouped, the mousemove events are synced across all charts to render + * uniformly aligned axis pointers. `isChartHovered` state is used to determine if the + * tooltip content should also be displayed or not. + */ + useEffect(() => { + if (!container || !groupId) { + return; + } + + container.addEventListener('mouseenter', () => toggleChartHover(true)); + container.addEventListener('mouseleave', () => toggleChartHover(false)); + + return () => { + container.removeEventListener('mouseenter', () => toggleChartHover(true)); + container.removeEventListener('mouseleave', () => + toggleChartHover(false), + ); + }; + }, [container, groupId, toggleChartHover]); + + /** + * Effect to turn on the tooltip event listeners when the chart is ready and tooltip + * is not already pinned. + */ + useEffect(() => { + if (!ready || tooltipPinned) { + return; + } + + on(EChartEvents.MouseMove, showTooltipOnMouseMove, { + useCanvasAsTrigger: true, + }); + on(EChartEvents.Click, pinTooltipOnClick, { + useCanvasAsTrigger: true, + }); + }, [on, pinTooltipOnClick, ready, showTooltipOnMouseMove, tooltipPinned]); + + /** + * Effect to add the event listeners to hide the tooltip when hovering a mark. + */ + useEffect(() => { + if (!ready) { + return; + } + + on(EChartEvents.MouseOver, hideTooltipOnMouseOverMark); + on(EChartEvents.MouseOut, stopHideTooltipOnMouseOutMark); + }, [ + pinTooltipOnClick, + showTooltipOnMouseMove, + hideTooltipOnMouseOverMark, + stopHideTooltipOnMouseOutMark, + hideTooltip, + off, + on, + ready, + tooltipPinned, + ]); + + /** + * Effect to react to the `tooltipPinned` state and show the tooltip. + * + * `setTimeout` is used to defer execution of callbacks to show tooltip and add unpin + * callback until the next tick of the event loop at which point the echarts tooltip + * is rendered and in the DOM + */ + useEffect(() => { + if (!tooltipPinned) { + return; + } + + const [x, y] = pinnedPosition; + setTimeout(() => { + showTooltip(x, y); + addUnpinCallbackToCloseButton(); + }, 0); + }, [ + isChartHovered, + tooltipPinned, + pinnedPosition, + showTooltip, + addUnpinCallbackToCloseButton, + ]); + + return { + isChartHovered, + setTooltipMounted, + tooltipPinned, + }; +}; diff --git a/charts/core/src/Chart/hooks/useTooltipVisibility.types.ts b/charts/core/src/Chart/hooks/useTooltipVisibility.types.ts new file mode 100644 index 0000000000..fba9cc44a4 --- /dev/null +++ b/charts/core/src/Chart/hooks/useTooltipVisibility.types.ts @@ -0,0 +1,20 @@ +export interface UseTooltipVisibilityReturnObj { + /** + * Whether the chart is hovered. + * When charts are grouped, the mousemove events are synced across all + * charts to render uniformly aligned axis pointers. This boolean is used + * to determine if the tooltip content should also be displayed or not. + */ + isChartHovered: boolean; + + /** + * React dispatch function toggled to true when the tooltip is mounted + * and false when unmounted. + */ + setTooltipMounted: React.Dispatch>; + + /** + * Whether the tooltip is visible and pinned. + */ + tooltipPinned: boolean; +} diff --git a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.stories.tsx b/charts/core/src/ChartTooltip/ChartTooltip.stories.tsx similarity index 63% rename from charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.stories.tsx rename to charts/core/src/ChartTooltip/ChartTooltip.stories.tsx index 649bd53612..8f94419bdb 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.stories.tsx +++ b/charts/core/src/ChartTooltip/ChartTooltip.stories.tsx @@ -1,21 +1,56 @@ import React from 'react'; import { storybookArgTypes } from '@lg-tools/storybook-utils'; -import type { StoryObj } from '@storybook/react'; +import type { StoryFn, StoryObj } from '@storybook/react'; +import { css } from '@leafygreen-ui/emotion'; import Icon from '@leafygreen-ui/icon'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { CustomTooltip } from './CustomTooltip'; -import { sampleTooltipParams } from './CustomTooltip.testUtils'; -import { CustomTooltipProps } from './CustomTooltip.types'; +import { getRootStylesText } from './ChartTooltip.styles'; +import { + CustomTooltip, + CustomTooltipProps, + sampleTooltipParams, +} from './CustomTooltip'; + +const TooltipRoot = (Story: StoryFn, ctx: any) => { + const rootClassName = css` + ${getRootStylesText(ctx.args.darkMode ? 'dark' : 'light')} + `; + + return ( + +
+ +
+
+ ); +}; export default { title: 'Charts/ChartTooltip', component: CustomTooltip, + decorators: [TooltipRoot], args: { seriesData: sampleTooltipParams, }, argTypes: { + chartId: { + table: { + disable: true, + }, + }, darkMode: storybookArgTypes.darkMode, + headerFormatter: { + table: { + disable: true, + }, + }, + seriesData: { + table: { + disable: true, + }, + }, seriesNameFormatter: { table: { disable: true, @@ -26,12 +61,19 @@ export default { disable: true, }, }, + sort: { + table: { + disable: true, + }, + }, }, parameters: { generate: { combineArgs: { darkMode: [false, true], + tooltipPinned: [false, true], }, + decorator: TooltipRoot, }, }, }; diff --git a/charts/core/src/ChartTooltip/ChartTooltip.styles.ts b/charts/core/src/ChartTooltip/ChartTooltip.styles.ts new file mode 100644 index 0000000000..07293cd7ae --- /dev/null +++ b/charts/core/src/ChartTooltip/ChartTooltip.styles.ts @@ -0,0 +1,25 @@ +import { Theme } from '@leafygreen-ui/lib'; +import { + borderRadius, + color, + fontFamilies, + fontWeights, + InteractionState, + Variant, +} from '@leafygreen-ui/tokens'; + +const TOOLTIP_WIDTH = 261; + +export const getRootStylesText = (theme: Theme) => ` + width: ${TOOLTIP_WIDTH}px; + overflow-y: auto; + background: ${ + color[theme].background[Variant.InversePrimary][InteractionState.Default] + }; + color: ${color[theme].text[Variant.InversePrimary][InteractionState.Default]}; + border-radius: ${borderRadius[150]}px; + font-family: ${fontFamilies.default}; + font-size: 12px; + line-height: 20px; + font-weight: ${fontWeights.regular}; +`; diff --git a/charts/core/src/ChartTooltip/ChartTooltip.tsx b/charts/core/src/ChartTooltip/ChartTooltip.tsx index 803a45f981..b6099df316 100644 --- a/charts/core/src/ChartTooltip/ChartTooltip.tsx +++ b/charts/core/src/ChartTooltip/ChartTooltip.tsx @@ -2,10 +2,10 @@ import React, { useEffect } from 'react'; import { renderToString } from 'react-dom/server'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { color, InteractionState, Variant } from '@leafygreen-ui/tokens'; import { useChartContext } from '../ChartContext'; +import { getRootStylesText } from './ChartTooltip.styles'; import { CallbackSeriesDataPoint, ChartTooltipProps, @@ -13,33 +13,57 @@ import { import { CustomTooltip } from './CustomTooltip'; export function ChartTooltip({ + headerFormatter, seriesValueFormatter, seriesNameFormatter, sort, }: ChartTooltipProps) { const { - chart: { ready, updateOptions }, + chart: { + id: chartId, + isChartHovered, + ready, + setTooltipMounted, + tooltipPinned, + updateOptions, + }, } = useChartContext(); - const { theme } = useDarkMode(); + const { darkMode, theme } = useDarkMode(); + + useEffect(() => { + setTooltipMounted(true); + + return () => { + setTooltipMounted(false); + }; + }, [isChartHovered, setTooltipMounted, tooltipPinned]); useEffect(() => { if (!ready) return; updateOptions({ tooltip: { - // Still adding background color to prevent peak of color at corners - backgroundColor: - color[theme].background[Variant.InversePrimary][ - InteractionState.Default - ], - borderWidth: 0, - enterable: false, - confine: true, + /* LOGIC PROPERTIES */ + alwaysShowContent: tooltipPinned, appendTo: 'body', + confine: true, + enterable: tooltipPinned, + renderMode: 'html', + showContent: isChartHovered || tooltipPinned, + trigger: 'axis', + triggerOn: 'none', + + /* STYLING PROPERTIES */ + /** + * using `extraCssText` instead of `className` because emotion-defined class + * didn't have high-enough specificity + */ + extraCssText: getRootStylesText(theme), + borderWidth: 0, + padding: 0, showDelay: 0, hideDelay: 0, transitionDuration: 0, - padding: 0, /** * Since the formatter trigger is set to 'axis', the seriesData will be * an array of objects. Additionally, it should contain axis related @@ -52,10 +76,14 @@ export function ChartTooltip({ return renderToString( , ); }, @@ -75,11 +103,16 @@ export function ChartTooltip({ }); }; }, [ + chartId, + darkMode, + headerFormatter, + isChartHovered, ready, seriesNameFormatter, seriesValueFormatter, sort, theme, + tooltipPinned, updateOptions, ]); diff --git a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.styles.ts b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.styles.ts index d001bc5317..308cd3ba99 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.styles.ts +++ b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.styles.ts @@ -1,33 +1,32 @@ import { css } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; import { - borderRadius, color, - fontFamilies, - fontWeights, InteractionState, spacing, Variant, } from '@leafygreen-ui/tokens'; -export const getContainerStyles = (theme: Theme) => css` - width: 261px; - overflow-y: auto; - background: ${color[theme].background[Variant.InversePrimary][ - InteractionState.Default - ]}; - color: ${color[theme].text[Variant.InversePrimary][InteractionState.Default]}; - padding: ${spacing[150]}px; - border-radius: ${borderRadius[150]}px; - font-family: ${fontFamilies.default}; - font-size: 12px; - line-height: 20px; - font-weight: ${fontWeights.regular}; -`; +const CLOSE_BUTTON_SIZE = 16; export const getHeaderStyles = (theme: Theme) => css` color: ${color[theme].text[Variant.InverseSecondary][ InteractionState.Default ]}; margin-bottom: ${spacing[100]}px; + padding: ${spacing[150]}px ${spacing[150]}px 0; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const closeButtonStyles = css` + height: ${CLOSE_BUTTON_SIZE}px; + width: ${CLOSE_BUTTON_SIZE}px; +`; + +export const pinTooltipNoteStyles = css` + display: flex; + align-items: center; + gap: ${spacing[50]}px; `; diff --git a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.tsx b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.tsx index 0a18dbed45..10e5cbb1eb 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.tsx +++ b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.tsx @@ -1,8 +1,15 @@ import React, { ReactNode } from 'react'; +import CursorIcon from '@leafygreen-ui/icon/dist/Cursor'; +import XIcon from '@leafygreen-ui/icon/dist/X'; +import IconButton from '@leafygreen-ui/icon-button'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { getContainerStyles, getHeaderStyles } from './CustomTooltip.styles'; +import { + closeButtonStyles, + getHeaderStyles, + pinTooltipNoteStyles, +} from './CustomTooltip.styles'; import { CustomTooltipProps } from './CustomTooltip.types'; import { SeriesList } from './SeriesList'; @@ -21,12 +28,14 @@ function formatDate(dateTimeStamp: number) { } export function CustomTooltip({ + chartId, + darkMode, + headerFormatter, seriesData, - seriesValueFormatter, seriesNameFormatter, - headerFormatter, + seriesValueFormatter, sort, - darkMode, + tooltipPinned, }: CustomTooltipProps) { const { theme } = useDarkMode(darkMode); @@ -50,14 +59,32 @@ export function CustomTooltip({ } return ( -
-
{axisValueLabel}
+ <> +
+ {axisValueLabel} + {tooltipPinned ? ( + + + + ) : ( +
+ + Click to Pin +
+ )} +
-
+ ); } diff --git a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.types.tsx b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.types.tsx index 8c53f68d8d..88b9e2bf79 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.types.tsx +++ b/charts/core/src/ChartTooltip/CustomTooltip/CustomTooltip.types.tsx @@ -6,9 +6,11 @@ import { } from '../ChartTooltip.types'; export interface CustomTooltipProps extends DarkModeProps { + chartId: string; + headerFormatter?: ChartTooltipProps['headerFormatter']; seriesData: Array; - sort?: ChartTooltipProps['sort']; - seriesValueFormatter?: ChartTooltipProps['seriesValueFormatter']; seriesNameFormatter?: ChartTooltipProps['seriesNameFormatter']; - headerFormatter?: ChartTooltipProps['headerFormatter']; + seriesValueFormatter?: ChartTooltipProps['seriesValueFormatter']; + sort?: ChartTooltipProps['sort']; + tooltipPinned: boolean; } diff --git a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.styles.ts b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.styles.ts index e69de29bb2..34d659a457 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.styles.ts +++ b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.styles.ts @@ -0,0 +1,13 @@ +import { css } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +const PINNED_SERIES_LIST_MAX_HEIGHT = 102; + +export const getSeriesListStyles = (tooltipPinned: boolean) => css` + all: unset; + overflow-y: auto; + max-height: ${tooltipPinned ? `${PINNED_SERIES_LIST_MAX_HEIGHT}px` : 'none'}; + padding: 0 ${spacing[150]}px ${spacing[150]}px; + display: grid; + gap: ${spacing[100]}px; +`; diff --git a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.tsx b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.tsx index e5f83071e3..b7c36607a3 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.tsx +++ b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.tsx @@ -4,6 +4,7 @@ import { SeriesName } from '@lg-charts/series-provider'; import { OptionDataValue } from '../../ChartTooltip.types'; import { SeriesListItem } from '../SeriesListItem'; +import { getSeriesListStyles } from './SeriesList.styles'; import { SeriesListProps } from './SeriesList.types'; function descendingCompareFn(valueA: OptionDataValue, valueB: OptionDataValue) { @@ -23,9 +24,10 @@ export function SeriesList({ seriesValueFormatter, seriesNameFormatter, sort, + tooltipPinned, }: SeriesListProps) { return ( - <> +
    {seriesData .sort((a, b) => { const [nameA, valueA] = a.data; @@ -50,6 +52,6 @@ export function SeriesList({ seriesNameFormatter={seriesNameFormatter} /> ))} - +
); } diff --git a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.types.ts b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.types.ts index 4838719760..47cd264437 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.types.ts +++ b/charts/core/src/ChartTooltip/CustomTooltip/SeriesList/SeriesList.types.ts @@ -5,4 +5,5 @@ export interface SeriesListProps { seriesValueFormatter?: CustomTooltipProps['seriesValueFormatter']; seriesNameFormatter?: CustomTooltipProps['seriesNameFormatter']; sort: CustomTooltipProps['sort']; + tooltipPinned: CustomTooltipProps['tooltipPinned']; } diff --git a/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.styles.ts b/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.styles.ts index 5d3045fc1c..3f0a938882 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.styles.ts +++ b/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.styles.ts @@ -4,7 +4,6 @@ import { fontFamilies, fontWeights, spacing } from '@leafygreen-ui/tokens'; export const containerStyle = css` display: grid; grid-template-columns: 1fr auto; - margin-bottom: ${spacing[100]}px; gap: ${spacing[500]}px; `; diff --git a/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.tsx b/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.tsx index e2e4878e6d..e97c3a976b 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.tsx +++ b/charts/core/src/ChartTooltip/CustomTooltip/SeriesListItem/SeriesListItem.tsx @@ -32,12 +32,12 @@ export const SeriesListItem = ({ } return ( -
+
  • {name}
    {value}
    -
  • + ); }; diff --git a/charts/core/src/ChartTooltip/CustomTooltip/index.ts b/charts/core/src/ChartTooltip/CustomTooltip/index.ts index bfed1fe356..c5b2620a97 100644 --- a/charts/core/src/ChartTooltip/CustomTooltip/index.ts +++ b/charts/core/src/ChartTooltip/CustomTooltip/index.ts @@ -1 +1,3 @@ export { CustomTooltip } from './CustomTooltip'; +export { sampleTooltipParams } from './CustomTooltip.testUtils'; +export type { CustomTooltipProps } from './CustomTooltip.types'; diff --git a/charts/core/src/Echart/Echart.types.ts b/charts/core/src/Echart/Echart.types.ts index 4b2d9d51bf..ce70cb0390 100644 --- a/charts/core/src/Echart/Echart.types.ts +++ b/charts/core/src/Echart/Echart.types.ts @@ -99,10 +99,15 @@ export interface EChartSetupZoomSelectProps { } interface EChartsEventHandlerType { - (event: EChartEventsType, callback: (params: any) => void): void; + ( + event: EChartEventsType, + callback: (params: any) => void, + options?: Partial<{ useCanvasAsTrigger: boolean }>, + ): void; ( event: 'zoomselect', callback: (params: EChartZoomSelectionEvent) => void, + options?: Partial<{ useCanvasAsTrigger: boolean }>, ): void; } @@ -122,6 +127,7 @@ export interface EChartsInstance { removeSeries: (name: string) => void; resize: () => void; setupZoomSelect: (props: EChartSetupZoomSelectProps) => void; + showTooltip: (x: number, y: number) => void; updateOptions: (options: Omit, 'series'>) => void; } diff --git a/charts/core/src/Echart/useEchart.ts b/charts/core/src/Echart/useEchart.ts index d7152e11bf..4e2564959b 100644 --- a/charts/core/src/Echart/useEchart.ts +++ b/charts/core/src/Echart/useEchart.ts @@ -122,11 +122,11 @@ export function useEchart({ */ const isZoomed = params?.start !== 0 || params?.end !== 100; - if (!isZoomed) { + if (!isZoomed || !echartsInstance) { return; } - echartsInstance?.dispatchAction({ + echartsInstance.dispatchAction({ type: 'dataZoom', start: 0, // percentage of starting position end: 100, // percentage of ending position @@ -135,7 +135,12 @@ export function useEchart({ const enableZoom = useCallback(() => { const echartsInstance = echartsInstanceRef.current; - echartsInstance?.dispatchAction({ + + if (!echartsInstance) { + return; + } + + echartsInstance.dispatchAction({ type: 'takeGlobalCursor', key: 'dataZoomSelect', dataZoomSelectActive: true, @@ -144,7 +149,12 @@ export function useEchart({ const disableZoom = useCallback(() => { const echartsInstance = echartsInstanceRef.current; - echartsInstance?.dispatchAction({ + + if (!echartsInstance) { + return; + } + + echartsInstance.dispatchAction({ type: 'takeGlobalCursor', key: 'dataZoomSelect', dataZoomSelectActive: false, @@ -155,6 +165,10 @@ export function useEchart({ ({ xAxis, yAxis }) => { const echartsInstance = echartsInstanceRef.current; + if (!echartsInstance) { + return; + } + // `0` index enables zoom on that index, `'none'` disables zoom on that index const xAxisIndex: number | string = xAxis ? 0 : 'none'; const yAxisIndex: number | string = yAxis ? 0 : 'none'; @@ -170,33 +184,46 @@ export function useEchart({ }, }); - echartsInstance?.off('dataZoom', clearDataZoom); // prevent adding dupes - echartsInstance?.on('dataZoom', clearDataZoom); + echartsInstance.off('dataZoom', clearDataZoom); // prevent adding dupes + echartsInstance.on('dataZoom', clearDataZoom); }, [clearDataZoom, updateOptions], ); - const off: EChartsInstance['off'] = useCallback((action, callback) => { - const echartsInstance = echartsInstanceRef.current; + const off: EChartsInstance['off'] = useCallback( + (action, callback, options) => { + const echartsInstance = echartsInstanceRef.current; - switch (action) { - case EChartEvents.ZoomSelect: { - echartsInstance?.off('datazoom', callback); - // Remove from active handlers - activeHandlers.current.delete(`${action}-${callback.toString()}`); - break; + if (!echartsInstance) { + return; } - default: { - echartsInstance?.off(action, callback); - activeHandlers.current.delete(`${action}-${callback.toString()}`); + switch (action) { + case EChartEvents.ZoomSelect: { + echartsInstance.off('datazoom', callback); + // Remove from active handlers + activeHandlers.current.delete(`${action}-${callback.toString()}`); + break; + } + + default: { + options?.useCanvasAsTrigger + ? echartsInstance.getZr().off(action, callback) + : echartsInstance.off(action, callback); + activeHandlers.current.delete(`${action}-${callback.toString()}`); + } } - } - }, []); + }, + [], + ); - const on: EChartsInstance['on'] = useCallback((action, callback) => { + const on: EChartsInstance['on'] = useCallback((action, callback, options) => { const echartsInstance = echartsInstanceRef.current; + if (!echartsInstance) { + return; + } + // Create a unique key for this handler const handlerKey = `${action}-${callback.toString()}`; @@ -240,27 +267,53 @@ export function useEchart({ // Store the wrapper function so we can remove it later activeHandlers.current.set(handlerKey, zoomHandler); - echartsInstance?.on('datazoom', zoomHandler); + echartsInstance.on('datazoom', zoomHandler); break; } default: { activeHandlers.current.set(handlerKey, callback); - echartsInstance?.on(action, callback as (...args: any) => void); + options?.useCanvasAsTrigger + ? echartsInstance.getZr().on(action, callback) + : echartsInstance.on(action, callback as (...args: any) => void); } } }, []); + const showTooltip: EChartsInstance['showTooltip'] = useCallback((x, y) => { + const echartsInstance = echartsInstanceRef.current; + + if (!echartsInstance) { + return; + } + + echartsInstance.dispatchAction({ + type: 'showTip', + x, + y, + }); + }, []); + const hideTooltip = useCallback(() => { const echartsInstance = echartsInstanceRef.current; - echartsInstance?.dispatchAction({ + + if (!echartsInstance) { + return; + } + + echartsInstance.dispatchAction({ type: 'hideTip', }); }, []); const resize = useCallback(() => { const echartsInstance = echartsInstanceRef.current; - echartsInstance?.resize(); + + if (!echartsInstance) { + return; + } + + echartsInstance.resize(); }, []); /** @@ -356,7 +409,14 @@ export function useEchart({ }, [ready, theme]); /** + * UPDATING OPTIONS --------------------- + * Sets the options on the instance when the options meaningfully change. + * + * The `notMerge` option set to true means that all of the current echarts + * components will be removed and new components will be created according + * to the new options object. * + * API docs: https://echarts.apache.org/en/api.html#echartsInstance.setOption */ useEffect(() => { const echartsInstance = echartsInstanceRef.current; @@ -390,6 +450,7 @@ export function useEchart({ removeSeries, resize, setupZoomSelect, + showTooltip, updateOptions, }; } diff --git a/charts/core/tsconfig.json b/charts/core/tsconfig.json index 0f776e1771..1e13eb328d 100644 --- a/charts/core/tsconfig.json +++ b/charts/core/tsconfig.json @@ -31,6 +31,9 @@ { "path": "../../packages/icon" }, + { + "path": "../../packages/icon-button" + }, { "path": "../../packages/leafygreen-provider" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f216121eec..1264266bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,12 @@ importers: '@leafygreen-ui/hooks': specifier: workspace:^ version: link:../../packages/hooks + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../../packages/icon + '@leafygreen-ui/icon-button': + specifier: workspace:^ + version: link:../../packages/icon-button '@leafygreen-ui/leafygreen-provider': specifier: workspace:^ version: link:../../packages/leafygreen-provider @@ -221,15 +227,9 @@ importers: '@faker-js/faker': specifier: 8.0.2 version: 8.0.2 - '@leafygreen-ui/icon': - specifier: workspace:^ - version: link:../../packages/icon '@lg-tools/build': specifier: workspace:^ version: link:../../tools/build - '@types/lodash.debounce': - specifier: ^4.0.9 - version: 4.0.9 charts/drag-provider: dependencies: @@ -6266,9 +6266,6 @@ packages: '@types/jsonfile@6.1.1': resolution: {integrity: sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==} - '@types/lodash.debounce@4.0.9': - resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==} - '@types/lodash@4.17.10': resolution: {integrity: sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==} @@ -14812,10 +14809,6 @@ snapshots: dependencies: '@types/node': 20.17.12 - '@types/lodash.debounce@4.0.9': - dependencies: - '@types/lodash': 4.17.10 - '@types/lodash@4.17.10': {} '@types/mdast@3.0.12':