From 3eed3d13dce6d80d9569d43ebc07489447b07794 Mon Sep 17 00:00:00 2001 From: Evgenii Alaev Date: Thu, 30 Jul 2026 21:26:29 +0200 Subject: [PATCH 1/5] feat: extract reusable SplitTooltip component --- .../SplitTooltip/SplitTooltip.test.ts | 58 +++++ src/components/SplitTooltip/SplitTooltip.tsx | 207 ++++++++++++++++++ src/components/SplitTooltip/index.ts | 2 + src/index.ts | 2 + ...-in-album-orientation-1-chromium-linux.png | Bin 69034 -> 69061 bytes ...-portrait-orientation-1-chromium-linux.png | Bin 25558 -> 25567 bytes .../__tests__/ChartTestStory.tsx | 6 +- .../__tests__/SplitTooltip.visual.test.tsx | 39 +++- .../withSplitPane/useWithSplitPaneState.ts | 73 ------ .../renderer/withSplitPane/withSplitPane.tsx | 180 ++------------- 10 files changed, 330 insertions(+), 237 deletions(-) create mode 100644 src/components/SplitTooltip/SplitTooltip.test.ts create mode 100644 src/components/SplitTooltip/SplitTooltip.tsx create mode 100644 src/components/SplitTooltip/index.ts delete mode 100644 src/plugins/gravity-charts/renderer/withSplitPane/useWithSplitPaneState.ts diff --git a/src/components/SplitTooltip/SplitTooltip.test.ts b/src/components/SplitTooltip/SplitTooltip.test.ts new file mode 100644 index 00000000..b2cbeadd --- /dev/null +++ b/src/components/SplitTooltip/SplitTooltip.test.ts @@ -0,0 +1,58 @@ +import {SplitLayout} from '../SplitPane'; + +import { + SPLIT_TOOLTIP_RESIZER_SIZE, + getSplitTooltipLayout, + getSplitTooltipMainPaneSize, + getSplitTooltipSizeLimits, +} from './SplitTooltip'; + +describe('SplitTooltip', () => { + test('uses horizontal layout in portrait orientation', () => { + expect(getSplitTooltipLayout(320, 640)).toBe(SplitLayout.HORIZONTAL); + }); + + test('uses vertical layout in landscape orientation', () => { + expect(getSplitTooltipLayout(640, 320)).toBe(SplitLayout.VERTICAL); + }); + + test('reserves tooltip height in horizontal layout', () => { + expect( + getSplitTooltipMainPaneSize({ + containerHeight: 640, + containerWidth: 320, + layout: SplitLayout.HORIZONTAL, + tooltipHeight: 120, + }), + ).toBe(640 - SPLIT_TOOLTIP_RESIZER_SIZE - 120); + }); + + test('uses container width ratio in vertical layout', () => { + expect( + getSplitTooltipMainPaneSize({ + containerHeight: 320, + containerWidth: 640, + layout: SplitLayout.VERTICAL, + tooltipHeight: 120, + }), + ).toBe(384); + }); + + test('does not produce a negative main pane size', () => { + expect( + getSplitTooltipMainPaneSize({ + containerHeight: 100, + containerWidth: 320, + layout: SplitLayout.HORIZONTAL, + tooltipHeight: 200, + }), + ).toBe(0); + }); + + test('keeps size limits valid when tooltip is higher than the container', () => { + expect(getSplitTooltipSizeLimits(100, 200)).toEqual({ + minSize: 0, + maxSize: 0, + }); + }); +}); diff --git a/src/components/SplitTooltip/SplitTooltip.tsx b/src/components/SplitTooltip/SplitTooltip.tsx new file mode 100644 index 00000000..3c1d9780 --- /dev/null +++ b/src/components/SplitTooltip/SplitTooltip.tsx @@ -0,0 +1,207 @@ +import React from 'react'; + +import {useResizeObserver} from '@gravity-ui/uikit'; + +import {SplitLayout, StyledSplitPane} from '../SplitPane'; +import type {SplitLayoutType} from '../SplitPane'; + +export const SPLIT_TOOLTIP_RESIZER_SIZE = 24; +export const SPLIT_TOOLTIP_MAIN_PANE_RATIO = 0.6; + +export type SplitTooltipRenderProps = { + layout: SplitLayoutType; + size: number; +}; + +export type SplitTooltipProps = { + renderMainPane: (props: SplitTooltipRenderProps) => React.ReactNode; + renderTooltip: (props: SplitTooltipRenderProps) => React.ReactNode; + tooltipVisible: boolean; + onMainPaneSizeChange?: (size: number, layout: SplitLayoutType) => void; + style?: React.CSSProperties; + mainPaneStyle?: React.CSSProperties; + tooltipPaneStyle?: React.CSSProperties; +}; + +type SplitTooltipContentProps = SplitTooltipProps & { + containerHeight: number; + containerWidth: number; +}; + +const hiddenResizerStyle = {display: 'none'}; +const defaultTooltipPaneStyle = {overflow: 'auto'}; + +export function getSplitTooltipLayout(width: number, height: number): SplitLayoutType { + return width > height ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; +} + +export function getSplitTooltipMainPaneSize({ + containerHeight, + containerWidth, + layout, + tooltipHeight, +}: { + containerHeight: number; + containerWidth: number; + layout: SplitLayoutType; + tooltipHeight: number; +}) { + if (layout === SplitLayout.VERTICAL) { + return containerWidth * SPLIT_TOOLTIP_MAIN_PANE_RATIO; + } + + return Math.max(0, containerHeight - SPLIT_TOOLTIP_RESIZER_SIZE - tooltipHeight); +} + +export function getSplitTooltipSizeLimits(containerHeight: number, tooltipHeight: number) { + const maxSize = getSplitTooltipMainPaneSize({ + containerHeight, + containerWidth: 0, + layout: SplitLayout.HORIZONTAL, + tooltipHeight, + }); + + return { + minSize: Math.min(containerHeight / 3, maxSize), + maxSize, + }; +} + +function SplitTooltipContent(props: SplitTooltipContentProps) { + const { + containerHeight, + containerWidth, + renderMainPane, + renderTooltip, + tooltipVisible, + onMainPaneSizeChange, + style, + mainPaneStyle, + tooltipPaneStyle, + } = props; + const tooltipRef = React.useRef(null); + const layout = getSplitTooltipLayout(containerWidth, containerHeight); + const [tooltipHeight, setTooltipHeight] = React.useState(0); + const [size, setSize] = React.useState(() => + getSplitTooltipMainPaneSize({ + containerHeight, + containerWidth, + layout, + tooltipHeight: 0, + }), + ); + const previousDimensionsRef = React.useRef({ + containerHeight, + containerWidth, + layout, + tooltipHeight, + }); + + const handleTooltipResize = React.useCallback(() => { + const nextTooltipHeight = tooltipRef.current?.getBoundingClientRect().height ?? 0; + setTooltipHeight(nextTooltipHeight); + }, []); + + useResizeObserver({ + ref: tooltipRef, + onResize: handleTooltipResize, + }); + + React.useLayoutEffect(() => { + const previousDimensions = previousDimensionsRef.current; + previousDimensionsRef.current = { + containerHeight, + containerWidth, + layout, + tooltipHeight, + }; + + if (layout !== previousDimensions.layout || layout === SplitLayout.VERTICAL) { + setSize( + getSplitTooltipMainPaneSize({ + containerHeight, + containerWidth, + layout, + tooltipHeight, + }), + ); + return; + } + + const {maxSize: previousMaxSize} = getSplitTooltipSizeLimits( + previousDimensions.containerHeight, + previousDimensions.tooltipHeight, + ); + const {minSize: nextMinSize, maxSize: nextMaxSize} = getSplitTooltipSizeLimits( + containerHeight, + tooltipHeight, + ); + + setSize((currentSize) => { + if (currentSize === previousMaxSize) { + return nextMaxSize; + } + + return Math.max(nextMinSize, Math.min(nextMaxSize, currentSize)); + }); + }, [containerHeight, containerWidth, layout, tooltipHeight]); + + React.useLayoutEffect(() => { + onMainPaneSizeChange?.(size, layout); + }, [layout, onMainPaneSizeChange, size]); + + const allowResize = layout === SplitLayout.HORIZONTAL; + const sizeLimits = getSplitTooltipSizeLimits(containerHeight, tooltipHeight); + const maxSize = allowResize ? sizeLimits.maxSize : undefined; + const minSize = allowResize ? sizeLimits.minSize : undefined; + const renderProps = {layout, size}; + + return ( + renderMainPane(renderProps)} + paneTwoRender={() =>
{renderTooltip(renderProps)}
} + pane1Style={mainPaneStyle} + pane2Style={{...defaultTooltipPaneStyle, ...tooltipPaneStyle}} + /> + ); +} + +export function SplitTooltip(props: SplitTooltipProps) { + const containerRef = React.useRef(null); + const [dimensions, setDimensions] = React.useState({height: 0, width: 0}); + + const updateDimensions = React.useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + setDimensions({ + height: rect?.height ?? 0, + width: rect?.width ?? 0, + }); + }, []); + + useResizeObserver({ + ref: containerRef, + onResize: updateDimensions, + }); + + React.useLayoutEffect(updateDimensions, [updateDimensions]); + + return ( +
+ {dimensions.height > 0 && dimensions.width > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/components/SplitTooltip/index.ts b/src/components/SplitTooltip/index.ts new file mode 100644 index 00000000..3af716ea --- /dev/null +++ b/src/components/SplitTooltip/index.ts @@ -0,0 +1,2 @@ +export {SplitTooltip} from './SplitTooltip'; +export type {SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; diff --git a/src/index.ts b/src/index.ts index 70e58154..78f5c78c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import {ChartKit} from './components/ChartKit'; import {settings} from './libs'; export * from './libs/chartkit-error/chartkit-error'; +export {SplitTooltip} from './components/SplitTooltip'; +export type {SplitTooltipProps, SplitTooltipRenderProps} from './components/SplitTooltip'; export type { ChartKitLang, diff --git a/src/plugins/gravity-charts/__screenshots__/SplitTooltip.visual.test.tsx-screenshots/Split-tooltip-visual-tests-should-render-tooltip-in-album-orientation-1-chromium-linux.png b/src/plugins/gravity-charts/__screenshots__/SplitTooltip.visual.test.tsx-screenshots/Split-tooltip-visual-tests-should-render-tooltip-in-album-orientation-1-chromium-linux.png index aceb41787e43333114ab7f081d8140424e296790..8e840b1f61bb0879a49bd51e8fc1a4dc6e9d0dce 100644 GIT binary patch literal 69061 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 KAKm?@7yciVv=e^- diff --git a/src/plugins/gravity-charts/__screenshots__/SplitTooltip.visual.test.tsx-screenshots/Split-tooltip-visual-tests-should-render-tooltip-in-portrait-orientation-1-chromium-linux.png b/src/plugins/gravity-charts/__screenshots__/SplitTooltip.visual.test.tsx-screenshots/Split-tooltip-visual-tests-should-render-tooltip-in-portrait-orientation-1-chromium-linux.png index 64c318a6a0585694ad2b7c6a22c562f1a6d93c77..8085ee51f740a9c28bd5d1325e7f285525e143a6 100644 GIT binary patch literal 25567 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 literal 25558 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 BgA)J% diff --git a/src/plugins/gravity-charts/__tests__/ChartTestStory.tsx b/src/plugins/gravity-charts/__tests__/ChartTestStory.tsx index f673c3b0..de189049 100644 --- a/src/plugins/gravity-charts/__tests__/ChartTestStory.tsx +++ b/src/plugins/gravity-charts/__tests__/ChartTestStory.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type {ChartData} from '@gravity-ui/charts'; import {ChartKit} from '../../../components/ChartKit.js'; +import type {ChartKitProps} from '../../../types/index.js'; export const CHART_TEST_STORY_DATA_QA = 'chart-test-story-data-qa'; @@ -10,9 +11,10 @@ type Props = { data: ChartData; styles?: React.CSSProperties; tooltip?: {splitted?: boolean}; + onRender?: ChartKitProps<'gravity-charts'>['onRender']; }; -export const ChartTestStory = ({data, styles, tooltip}: Props) => { +export const ChartTestStory = ({data, styles, tooltip, onRender}: Props) => { const containerStyle: React.CSSProperties = { height: '100vh', width: '100vw', @@ -21,7 +23,7 @@ export const ChartTestStory = ({data, styles, tooltip}: Props) => { return (
- +
); }; diff --git a/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx b/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx index 3cddb255..f1df9a14 100644 --- a/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx +++ b/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx @@ -25,28 +25,57 @@ const SPLIT_TOOLTIP_DATA: ChartData = { xAxis: {type: 'category', categories: ['A', 'B']}, }; -// TODO: flaky — screenshots captured before async chart render. Skipped until fixed. -// https://github.com/gravity-ui/chartkit/issues/896 -describe.skip('Split tooltip visual tests', () => { +function createRenderWaiter(expectedRenderCount: number) { + let renderCount = 0; + let resolveRender: () => void; + const rendered = new Promise((resolve) => { + resolveRender = resolve; + }); + + return { + rendered, + onRender() { + renderCount += 1; + + if (renderCount === expectedRenderCount) { + resolveRender(); + } + }, + }; +} + +describe('Split tooltip visual tests', () => { beforeAll(() => { settings.set({plugins: [GravityChartsPlugin]}); }); test('should render tooltip in album orientation', async () => { await page.viewport(600, 280); + const renderWaiter = createRenderWaiter(1); const screen = await render( - , + , {providers: {theme: 'dark'}}, ); + await renderWaiter.rendered; await expect(screen.getByTestId(CHART_TEST_STORY_DATA_QA)).toMatchScreenshot(); }); test('should render tooltip in portrait orientation', async () => { await page.viewport(280, 400); + const renderWaiter = createRenderWaiter(2); const screen = await render( - , + , {providers: {theme: 'dark'}}, ); + await renderWaiter.rendered; await expect(screen.getByTestId(CHART_TEST_STORY_DATA_QA)).toMatchScreenshot(); }); }); diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/useWithSplitPaneState.ts b/src/plugins/gravity-charts/renderer/withSplitPane/useWithSplitPaneState.ts deleted file mode 100644 index 9998a109..00000000 --- a/src/plugins/gravity-charts/renderer/withSplitPane/useWithSplitPaneState.ts +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; - -import {SplitLayout} from '../../../../components/SplitPane'; -import type {SplitLayoutType} from '../../../../components/SplitPane'; -import {IS_WINDOW_AVAILABLE} from '../../../../constants'; - -const CHART_SECTION_PERCENTAGE = 0.6; -export const RESIZER_HEIGHT = 24; - -type WithSplitPaneState = { - allowResize: boolean; - tooltipHeight: number; - setTooltipHeight: (value: number) => void; - split: SplitLayoutType; - setSplit: (value: SplitLayoutType) => void; - size: number | string; - setSize: (value: number | string) => void; - maxSize?: number; - minSize?: number; -}; - -function getInitialSplit(): SplitLayoutType { - if (!IS_WINDOW_AVAILABLE) { - return SplitLayout.HORIZONTAL; - } - - return window.innerWidth > window.innerHeight ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; -} - -type UseWithSplitPaneProps = { - containerHeight: number; -}; - -export function getVerticalSize() { - return window.innerWidth * CHART_SECTION_PERCENTAGE; -} - -function getInitialSize(split: SplitLayoutType, containerHeight: number) { - const defaultSize = containerHeight - RESIZER_HEIGHT; - - if (!IS_WINDOW_AVAILABLE) { - return defaultSize; - } - - return split === SplitLayout.VERTICAL ? getVerticalSize() : defaultSize; -} - -export function useWithSplitPaneState(props: UseWithSplitPaneProps): WithSplitPaneState { - const {containerHeight} = props; - const [tooltipHeight, setTooltipHeight] = React.useState(0); - const [split, setSplit] = React.useState(getInitialSplit()); - const [size, setSize] = React.useState(getInitialSize(split, containerHeight)); - const allowResize = split === SplitLayout.HORIZONTAL; - let maxSize: number | undefined; - let minSize: number | undefined; - - if (IS_WINDOW_AVAILABLE && split === SplitLayout.HORIZONTAL) { - maxSize = containerHeight - RESIZER_HEIGHT - tooltipHeight; - minSize = containerHeight / 3; - } - - return { - allowResize, - maxSize, - minSize, - tooltipHeight, - setTooltipHeight, - split, - setSplit, - size, - setSize, - }; -} diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx index 52ecc279..ffd68363 100644 --- a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx +++ b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx @@ -2,23 +2,13 @@ import React from 'react'; import {Chart, getDefaultTooltipHeaderFormat} from '@gravity-ui/charts'; import type {ChartData, ChartProps, ChartRef} from '@gravity-ui/charts'; -import {getComponentName, useResizeObserver} from '@gravity-ui/uikit'; +import {getComponentName} from '@gravity-ui/uikit'; import isEmpty from 'lodash/isEmpty'; -import { - SplitLayout, - StyledSplitPane, - mapScreenOrientationTypeToSplitLayout, -} from '../../../../components/SplitPane'; -import {IS_SCREEN_ORIENTATION_AVAILABLE, IS_WINDOW_AVAILABLE} from '../../../../constants'; -import {usePrevious} from '../../../../hooks'; -import {isScreenOrientationEventType} from '../../../../utils'; +import {SplitTooltip} from '../../../../components/SplitTooltip'; import {TooltipContent} from './TooltipContent'; import type {TooltipContentRef} from './TooltipContent'; -import {RESIZER_HEIGHT, getVerticalSize, useWithSplitPaneState} from './useWithSplitPaneState'; - -const tooltipPaneStyles = {overflow: 'auto'}; type WithSplitPaneProps = {}; @@ -26,62 +16,11 @@ type PointerMoveHandler = NonNullable< NonNullable['events']>['pointermove'] >; -const SplitPaneContent = ( - props: ChartProps & { - height: number; - ChartComponent: typeof Chart; - }, -) => { - const {data, height, ChartComponent, ...restProps} = props; - const tooltipContainerRef = React.useRef(null); +const SplitPaneContent = (props: ChartProps & {ChartComponent: typeof Chart}) => { + const {data, ChartComponent, ...restProps} = props; const chartRef = React.useRef(null); const tooltipRef = React.useRef(null); - const shouldShowTooltip = React.useRef(false); - const { - allowResize, - minSize, - maxSize, - tooltipHeight, - split, - size, - setTooltipHeight, - setSplit, - setSize, - } = useWithSplitPaneState({ - containerHeight: height, - }); - const prevTooltipHeight = usePrevious(tooltipHeight); - const prevSplit = usePrevious(split); - - if (prevSplit && split !== prevSplit && split === SplitLayout.VERTICAL) { - setSize(getVerticalSize()); - } else if ( - split === SplitLayout.HORIZONTAL && - prevTooltipHeight === 0 && - tooltipHeight !== prevTooltipHeight - ) { - const containerHeight = height; - if (containerHeight - RESIZER_HEIGHT === size) { - setSize(containerHeight - RESIZER_HEIGHT - tooltipHeight); - queueMicrotask(() => { - chartRef.current?.reflow({immediate: true}); - }); - } - } - - const handleTooltipContentResize = React.useCallback(() => { - if (!tooltipContainerRef.current || split !== SplitLayout.HORIZONTAL) { - return; - } - - const nextTooltipHeight = tooltipContainerRef.current.getBoundingClientRect().height; - setTooltipHeight(nextTooltipHeight); - }, [setTooltipHeight, split]); - - useResizeObserver({ - ref: tooltipContainerRef, - onResize: handleTooltipContentResize, - }); + const [tooltipVisible, setTooltipVisible] = React.useState(false); const headerFormat = React.useMemo(() => { return ( @@ -98,7 +37,7 @@ const SplitPaneContent = ( const userPointerMoveHandler = data.chart?.events?.pointermove; const pointerMoveHandler: PointerMoveHandler = (pointerMoveData, event) => { if (!isEmpty(pointerMoveData?.hovered)) { - shouldShowTooltip.current = true; + setTooltipVisible(true); tooltipRef.current?.redraw(pointerMoveData); } @@ -124,78 +63,24 @@ const SplitPaneContent = ( } satisfies ChartData; }, [data]); - const handleOrientationChange = React.useCallback(() => { - const deviceWidth = window.innerWidth; - const deviceHeight = window.innerHeight; - const nextSplit = - deviceWidth > deviceHeight ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; - setSplit(nextSplit); - }, [setSplit]); - - const handleScreenOrientationChange = React.useCallback( - (e: Event) => { - const type = e.target && 'type' in e.target && e.target.type; - - if (!isScreenOrientationEventType(type)) { - return; - } - - setSplit(mapScreenOrientationTypeToSplitLayout(type)); - }, - [setSplit], - ); - - const handleSizeChange = React.useCallback( - (nextSize: number) => { - chartRef.current?.reflow(); - - if (split === SplitLayout.HORIZONTAL) { - setSize(nextSize); - } - }, - [split, setSize], - ); - - React.useLayoutEffect(() => { - if (IS_SCREEN_ORIENTATION_AVAILABLE) { - screen.orientation.addEventListener('change', handleScreenOrientationChange); - } else if (IS_WINDOW_AVAILABLE) { - window.addEventListener('orientationchange', handleOrientationChange); - } - - return () => { - if (IS_SCREEN_ORIENTATION_AVAILABLE) { - screen.orientation.removeEventListener('change', handleScreenOrientationChange); - } else if (IS_WINDOW_AVAILABLE) { - window.removeEventListener('orientationchange', handleOrientationChange); - } - }; - }, [handleOrientationChange, handleScreenOrientationChange]); - return ( - } - paneTwoRender={() => ( -
- -
+ chartRef.current?.reflow()} + renderMainPane={() => ( + + )} + renderTooltip={() => ( + )} - pane2Style={tooltipPaneStyles} /> ); }; @@ -204,26 +89,7 @@ export function withSplitPane(ChartComponent: typeof Chart) { const componentName = getComponentName(ChartComponent); const component = React.forwardRef( function WithSplitPaneComponent(props, _ref) { - const containerRef = React.useRef(null); - const [height, setHeight] = React.useState(0); - - React.useEffect(() => { - if (containerRef.current) { - setHeight(containerRef.current.getBoundingClientRect().height ?? 0); - } - }, []); - - return ( -
- {height ? ( - - ) : null} -
- ); + return ; }, ); From 4f2e31a4e4b0bcfb081f2998baa0105d74916768 Mon Sep 17 00:00:00 2001 From: Evgenii Alaev Date: Thu, 30 Jul 2026 22:14:43 +0200 Subject: [PATCH 2/5] feat: support controlled SplitTooltip layout --- .../SplitTooltip/SplitTooltip.test.ts | 7 + src/components/SplitTooltip/SplitTooltip.tsx | 152 +++++++++++------ .../SplitTooltip/SplitTooltip.visual.test.tsx | 161 ++++++++++++++++++ src/components/SplitTooltip/index.ts | 2 +- src/index.ts | 6 +- .../renderer/withSplitPane/withSplitPane.tsx | 2 +- 6 files changed, 274 insertions(+), 56 deletions(-) create mode 100644 src/components/SplitTooltip/SplitTooltip.visual.test.tsx diff --git a/src/components/SplitTooltip/SplitTooltip.test.ts b/src/components/SplitTooltip/SplitTooltip.test.ts index b2cbeadd..18aeb58c 100644 --- a/src/components/SplitTooltip/SplitTooltip.test.ts +++ b/src/components/SplitTooltip/SplitTooltip.test.ts @@ -16,6 +16,13 @@ describe('SplitTooltip', () => { expect(getSplitTooltipLayout(640, 320)).toBe(SplitLayout.VERTICAL); }); + test('uses explicit layout instead of container orientation', () => { + expect(getSplitTooltipLayout(320, 640, SplitLayout.VERTICAL)).toBe(SplitLayout.VERTICAL); + expect(getSplitTooltipLayout(640, 320, SplitLayout.HORIZONTAL)).toBe( + SplitLayout.HORIZONTAL, + ); + }); + test('reserves tooltip height in horizontal layout', () => { expect( getSplitTooltipMainPaneSize({ diff --git a/src/components/SplitTooltip/SplitTooltip.tsx b/src/components/SplitTooltip/SplitTooltip.tsx index 3c1d9780..56ed01f9 100644 --- a/src/components/SplitTooltip/SplitTooltip.tsx +++ b/src/components/SplitTooltip/SplitTooltip.tsx @@ -8,16 +8,19 @@ import type {SplitLayoutType} from '../SplitPane'; export const SPLIT_TOOLTIP_RESIZER_SIZE = 24; export const SPLIT_TOOLTIP_MAIN_PANE_RATIO = 0.6; +export type SplitTooltipLayout = SplitLayoutType; + export type SplitTooltipRenderProps = { - layout: SplitLayoutType; + layout: SplitTooltipLayout; size: number; }; export type SplitTooltipProps = { renderMainPane: (props: SplitTooltipRenderProps) => React.ReactNode; renderTooltip: (props: SplitTooltipRenderProps) => React.ReactNode; - tooltipVisible: boolean; - onMainPaneSizeChange?: (size: number, layout: SplitLayoutType) => void; + layout?: SplitTooltipLayout; + resizerVisible: boolean; + onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; style?: React.CSSProperties; mainPaneStyle?: React.CSSProperties; tooltipPaneStyle?: React.CSSProperties; @@ -31,8 +34,12 @@ type SplitTooltipContentProps = SplitTooltipProps & { const hiddenResizerStyle = {display: 'none'}; const defaultTooltipPaneStyle = {overflow: 'auto'}; -export function getSplitTooltipLayout(width: number, height: number): SplitLayoutType { - return width > height ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; +export function getSplitTooltipLayout( + width: number, + height: number, + layout?: SplitTooltipLayout, +): SplitTooltipLayout { + return layout ?? (width > height ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL); } export function getSplitTooltipMainPaneSize({ @@ -43,7 +50,7 @@ export function getSplitTooltipMainPaneSize({ }: { containerHeight: number; containerWidth: number; - layout: SplitLayoutType; + layout: SplitTooltipLayout; tooltipHeight: number; }) { if (layout === SplitLayout.VERTICAL) { @@ -67,20 +74,60 @@ export function getSplitTooltipSizeLimits(containerHeight: number, tooltipHeight }; } +type SplitTooltipDimensions = { + containerHeight: number; + containerWidth: number; + layout: SplitTooltipLayout; + tooltipHeight: number; +}; + +function getNextMainPaneSize({ + currentSize, + nextDimensions, + previousDimensions, +}: { + currentSize: number; + nextDimensions: SplitTooltipDimensions; + previousDimensions: SplitTooltipDimensions; +}) { + if ( + nextDimensions.layout !== previousDimensions.layout || + nextDimensions.layout === SplitLayout.VERTICAL + ) { + return getSplitTooltipMainPaneSize(nextDimensions); + } + + const {maxSize: previousMaxSize} = getSplitTooltipSizeLimits( + previousDimensions.containerHeight, + previousDimensions.tooltipHeight, + ); + const {minSize: nextMinSize, maxSize: nextMaxSize} = getSplitTooltipSizeLimits( + nextDimensions.containerHeight, + nextDimensions.tooltipHeight, + ); + + if (currentSize === previousMaxSize) { + return nextMaxSize; + } + + return Math.max(nextMinSize, Math.min(nextMaxSize, currentSize)); +} + function SplitTooltipContent(props: SplitTooltipContentProps) { const { containerHeight, containerWidth, renderMainPane, renderTooltip, - tooltipVisible, + layout: layoutProp, + resizerVisible, onMainPaneSizeChange, style, mainPaneStyle, tooltipPaneStyle, } = props; const tooltipRef = React.useRef(null); - const layout = getSplitTooltipLayout(containerWidth, containerHeight); + const layout = getSplitTooltipLayout(containerWidth, containerHeight, layoutProp); const [tooltipHeight, setTooltipHeight] = React.useState(0); const [size, setSize] = React.useState(() => getSplitTooltipMainPaneSize({ @@ -90,12 +137,21 @@ function SplitTooltipContent(props: SplitTooltipContentProps) { tooltipHeight: 0, }), ); - const previousDimensionsRef = React.useRef({ - containerHeight, - containerWidth, - layout, - tooltipHeight, - }); + const dimensions = React.useMemo( + () => ({ + containerHeight, + containerWidth, + layout, + tooltipHeight, + }), + [containerHeight, containerWidth, layout, tooltipHeight], + ); + const previousDimensionsRef = React.useRef(dimensions); + const lastNotificationRef = React.useRef<{layout: SplitTooltipLayout; size: number} | null>( + null, + ); + const onMainPaneSizeChangeRef = React.useRef(onMainPaneSizeChange); + onMainPaneSizeChangeRef.current = onMainPaneSizeChange; const handleTooltipResize = React.useCallback(() => { const nextTooltipHeight = tooltipRef.current?.getBoundingClientRect().height ?? 0; @@ -107,65 +163,55 @@ function SplitTooltipContent(props: SplitTooltipContentProps) { onResize: handleTooltipResize, }); + const effectiveSize = getNextMainPaneSize({ + currentSize: size, + nextDimensions: dimensions, + previousDimensions: previousDimensionsRef.current, + }); + React.useLayoutEffect(() => { - const previousDimensions = previousDimensionsRef.current; - previousDimensionsRef.current = { - containerHeight, - containerWidth, - layout, - tooltipHeight, - }; - - if (layout !== previousDimensions.layout || layout === SplitLayout.VERTICAL) { - setSize( - getSplitTooltipMainPaneSize({ - containerHeight, - containerWidth, - layout, - tooltipHeight, - }), - ); - return; + previousDimensionsRef.current = dimensions; + + if (effectiveSize !== size) { + setSize(effectiveSize); } + }, [dimensions, effectiveSize, size]); - const {maxSize: previousMaxSize} = getSplitTooltipSizeLimits( - previousDimensions.containerHeight, - previousDimensions.tooltipHeight, - ); - const {minSize: nextMinSize, maxSize: nextMaxSize} = getSplitTooltipSizeLimits( - containerHeight, - tooltipHeight, - ); + React.useLayoutEffect(() => { + const previousNotification = lastNotificationRef.current; - setSize((currentSize) => { - if (currentSize === previousMaxSize) { - return nextMaxSize; - } + if ( + previousNotification?.size === effectiveSize && + previousNotification.layout === layout + ) { + return undefined; + } - return Math.max(nextMinSize, Math.min(nextMaxSize, currentSize)); - }); - }, [containerHeight, containerWidth, layout, tooltipHeight]); + const callback = onMainPaneSizeChangeRef.current; + if (!callback) { + return undefined; + } - React.useLayoutEffect(() => { - onMainPaneSizeChange?.(size, layout); - }, [layout, onMainPaneSizeChange, size]); + lastNotificationRef.current = {layout, size: effectiveSize}; + return callback(effectiveSize, layout); + }); const allowResize = layout === SplitLayout.HORIZONTAL; const sizeLimits = getSplitTooltipSizeLimits(containerHeight, tooltipHeight); const maxSize = allowResize ? sizeLimits.maxSize : undefined; const minSize = allowResize ? sizeLimits.minSize : undefined; - const renderProps = {layout, size}; + const renderProps = {layout, size: effectiveSize}; return ( renderMainPane(renderProps)} paneTwoRender={() =>
{renderTooltip(renderProps)}
} pane1Style={mainPaneStyle} diff --git a/src/components/SplitTooltip/SplitTooltip.visual.test.tsx b/src/components/SplitTooltip/SplitTooltip.visual.test.tsx new file mode 100644 index 00000000..80fa9136 --- /dev/null +++ b/src/components/SplitTooltip/SplitTooltip.visual.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; + +import {render} from '../../../test-utils/utils.js'; +import {SplitLayout} from '../SplitPane/index.js'; + +import {SplitTooltip} from './SplitTooltip.js'; +import type {SplitTooltipLayout} from './SplitTooltip.js'; + +type TestCaseProps = { + containerHeight: number; + containerWidth: number; + layout?: SplitTooltipLayout; + resizerVisible?: boolean; + tooltipHeight: number; + onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; +}; + +function TestCase({ + containerHeight, + containerWidth, + layout, + resizerVisible = true, + tooltipHeight, + onMainPaneSizeChange, +}: TestCaseProps) { + return ( +
+ ( +
{`${actualLayout}:${size}`}
+ )} + renderTooltip={({layout: actualLayout, size}) => ( +
+ {`${actualLayout}:${size}`} +
+ )} + /> +
+ ); +} + +describe('SplitTooltip behavior', () => { + test('explicit layout overrides automatic layout and reaches both render functions', async () => { + const screen = await render( + , + ); + + await expect.element(screen.getByTestId('main-pane')).toHaveTextContent('vertical:120'); + await expect.element(screen.getByTestId('tooltip-pane')).toHaveTextContent('vertical:120'); + }); + + test('selects layout automatically when explicit layout is omitted', async () => { + const screen = await render( + , + ); + + await expect.element(screen.getByTestId('main-pane')).toHaveTextContent('vertical:240'); + + await screen.rerender( + , + ); + + await expect.element(screen.getByTestId('main-pane')).toHaveTextContent('horizontal:336'); + await expect + .element(screen.getByTestId('tooltip-pane')) + .toHaveTextContent('horizontal:336'); + }); + + test('recalculates size and reports only distinct size-layout pairs', async () => { + const onMainPaneSizeChange = vi.fn(); + const renderTestCase = (props?: Partial) => ( + onMainPaneSizeChange(size, layout)} + {...props} + /> + ); + const screen = await render(renderTestCase()); + + await expect + .poll(() => onMainPaneSizeChange.mock.calls.at(-1)) + .toEqual([336, SplitLayout.HORIZONTAL]); + + const callCountAfterInitialization = onMainPaneSizeChange.mock.calls.length; + await screen.rerender(renderTestCase()); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + expect(onMainPaneSizeChange).toHaveBeenCalledTimes(callCountAfterInitialization); + + await screen.rerender(renderTestCase({containerHeight: 500})); + await expect + .poll(() => onMainPaneSizeChange.mock.calls.at(-1)) + .toEqual([436, SplitLayout.HORIZONTAL]); + + await screen.rerender(renderTestCase({containerHeight: 500, tooltipHeight: 80})); + await expect + .poll(() => onMainPaneSizeChange.mock.calls.at(-1)) + .toEqual([396, SplitLayout.HORIZONTAL]); + + await screen.rerender( + renderTestCase({ + containerHeight: 500, + layout: SplitLayout.VERTICAL, + tooltipHeight: 80, + }), + ); + await expect + .poll(() => onMainPaneSizeChange.mock.calls.at(-1)) + .toEqual([180, SplitLayout.VERTICAL]); + + const distinctCalls = onMainPaneSizeChange.mock.calls.map(([size, layout]) => ({ + layout, + size, + })); + expect(distinctCalls).toEqual( + distinctCalls.filter( + (call, index) => + index === 0 || + call.layout !== distinctCalls[index - 1].layout || + call.size !== distinctCalls[index - 1].size, + ), + ); + }); + + test('resizerVisible controls the resizer without hiding tooltip content', async () => { + const screen = await render( + , + ); + + const resizer = screen.container.querySelector('.Resizer'); + await expect.element(resizer).toHaveStyle({display: 'none'}); + await expect.element(screen.getByTestId('tooltip-pane')).toBeVisible(); + + await screen.rerender( + , + ); + + expect(resizer?.style.display).toBe(''); + await expect.element(screen.getByTestId('tooltip-pane')).toBeVisible(); + }); +}); diff --git a/src/components/SplitTooltip/index.ts b/src/components/SplitTooltip/index.ts index 3af716ea..6c82adbe 100644 --- a/src/components/SplitTooltip/index.ts +++ b/src/components/SplitTooltip/index.ts @@ -1,2 +1,2 @@ export {SplitTooltip} from './SplitTooltip'; -export type {SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; +export type {SplitTooltipLayout, SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; diff --git a/src/index.ts b/src/index.ts index 78f5c78c..fb514c4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,11 @@ import {settings} from './libs'; export * from './libs/chartkit-error/chartkit-error'; export {SplitTooltip} from './components/SplitTooltip'; -export type {SplitTooltipProps, SplitTooltipRenderProps} from './components/SplitTooltip'; +export type { + SplitTooltipLayout, + SplitTooltipProps, + SplitTooltipRenderProps, +} from './components/SplitTooltip'; export type { ChartKitLang, diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx index ffd68363..822a0049 100644 --- a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx +++ b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx @@ -65,7 +65,7 @@ const SplitPaneContent = (props: ChartProps & {ChartComponent: typeof Chart}) => return ( chartRef.current?.reflow()} renderMainPane={() => ( From f3d5de9233f170265afdb5d3e96d4cb5eaf4cdd9 Mon Sep 17 00:00:00 2001 From: Evgenii Alaev Date: Thu, 30 Jul 2026 22:36:29 +0200 Subject: [PATCH 3/5] fix: refine SplitTooltip layout API --- .../SplitTooltip/SplitTooltip.test.ts | 8 ++++ src/components/SplitTooltip/SplitTooltip.tsx | 30 ++++++++++---- .../SplitTooltip/SplitTooltip.visual.test.tsx | 11 ++--- src/components/SplitTooltip/index.ts | 4 +- src/index.ts | 8 +--- .../__tests__/SplitTooltip.visual.test.tsx | 19 +++++++++ .../useViewportSplitTooltipLayout.test.ts | 12 ++++++ .../useViewportSplitTooltipLayout.ts | 40 +++++++++++++++++++ .../renderer/withSplitPane/withSplitPane.tsx | 3 ++ 9 files changed, 111 insertions(+), 24 deletions(-) create mode 100644 src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.test.ts create mode 100644 src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.ts diff --git a/src/components/SplitTooltip/SplitTooltip.test.ts b/src/components/SplitTooltip/SplitTooltip.test.ts index 18aeb58c..af86c6a7 100644 --- a/src/components/SplitTooltip/SplitTooltip.test.ts +++ b/src/components/SplitTooltip/SplitTooltip.test.ts @@ -2,12 +2,20 @@ import {SplitLayout} from '../SplitPane'; import { SPLIT_TOOLTIP_RESIZER_SIZE, + SplitTooltipLayout, getSplitTooltipLayout, getSplitTooltipMainPaneSize, getSplitTooltipSizeLimits, } from './SplitTooltip'; describe('SplitTooltip', () => { + test('exposes layout values as part of the public component API', () => { + const layout: SplitTooltipLayout = SplitTooltipLayout.VERTICAL; + + expect(layout).toBe('vertical'); + expect(SplitTooltipLayout.HORIZONTAL).toBe('horizontal'); + }); + test('uses horizontal layout in portrait orientation', () => { expect(getSplitTooltipLayout(320, 640)).toBe(SplitLayout.HORIZONTAL); }); diff --git a/src/components/SplitTooltip/SplitTooltip.tsx b/src/components/SplitTooltip/SplitTooltip.tsx index 56ed01f9..7866e005 100644 --- a/src/components/SplitTooltip/SplitTooltip.tsx +++ b/src/components/SplitTooltip/SplitTooltip.tsx @@ -8,23 +8,37 @@ import type {SplitLayoutType} from '../SplitPane'; export const SPLIT_TOOLTIP_RESIZER_SIZE = 24; export const SPLIT_TOOLTIP_MAIN_PANE_RATIO = 0.6; +export {SplitLayout as SplitTooltipLayout}; export type SplitTooltipLayout = SplitLayoutType; -export type SplitTooltipRenderProps = { +export interface SplitTooltipRenderProps { + /** The active pane layout. */ layout: SplitTooltipLayout; + /** The current size of the main pane in pixels. */ size: number; -}; +} -export type SplitTooltipProps = { - renderMainPane: (props: SplitTooltipRenderProps) => React.ReactNode; - renderTooltip: (props: SplitTooltipRenderProps) => React.ReactNode; +export interface SplitTooltipProps { + /** + * The controlled pane layout. When omitted, the layout is derived from the container size: + * vertical when width is greater than height, horizontal otherwise. + */ layout?: SplitTooltipLayout; - resizerVisible: boolean; + /** Additional styles for the main pane. */ + mainPaneStyle?: React.CSSProperties; + /** Called after initialization and whenever the main pane size or layout changes. */ onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; + /** Renders the main pane with its current layout and size. */ + renderMainPane: (props: SplitTooltipRenderProps) => React.ReactNode; + /** Renders the tooltip pane with the current main pane layout and size. */ + renderTooltip: (props: SplitTooltipRenderProps) => React.ReactNode; + /** Controls the resizer visibility without hiding the tooltip pane. Defaults to `false`. */ + resizerVisible?: boolean; + /** Additional styles for the split pane container. */ style?: React.CSSProperties; - mainPaneStyle?: React.CSSProperties; + /** Additional styles for the tooltip pane. */ tooltipPaneStyle?: React.CSSProperties; -}; +} type SplitTooltipContentProps = SplitTooltipProps & { containerHeight: number; diff --git a/src/components/SplitTooltip/SplitTooltip.visual.test.tsx b/src/components/SplitTooltip/SplitTooltip.visual.test.tsx index 80fa9136..272bcde0 100644 --- a/src/components/SplitTooltip/SplitTooltip.visual.test.tsx +++ b/src/components/SplitTooltip/SplitTooltip.visual.test.tsx @@ -19,7 +19,7 @@ function TestCase({ containerHeight, containerWidth, layout, - resizerVisible = true, + resizerVisible, tooltipHeight, onMainPaneSizeChange, }: TestCaseProps) { @@ -132,14 +132,9 @@ describe('SplitTooltip behavior', () => { ); }); - test('resizerVisible controls the resizer without hiding tooltip content', async () => { + test('resizerVisible is false by default and does not hide tooltip content', async () => { const screen = await render( - , + , ); const resizer = screen.container.querySelector('.Resizer'); diff --git a/src/components/SplitTooltip/index.ts b/src/components/SplitTooltip/index.ts index 6c82adbe..e24ede83 100644 --- a/src/components/SplitTooltip/index.ts +++ b/src/components/SplitTooltip/index.ts @@ -1,2 +1,2 @@ -export {SplitTooltip} from './SplitTooltip'; -export type {SplitTooltipLayout, SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; +export {SplitTooltip, SplitTooltipLayout} from './SplitTooltip'; +export type {SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; diff --git a/src/index.ts b/src/index.ts index fb514c4f..3d17fd9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,8 @@ import {ChartKit} from './components/ChartKit'; import {settings} from './libs'; export * from './libs/chartkit-error/chartkit-error'; -export {SplitTooltip} from './components/SplitTooltip'; -export type { - SplitTooltipLayout, - SplitTooltipProps, - SplitTooltipRenderProps, -} from './components/SplitTooltip'; +export {SplitTooltip, SplitTooltipLayout} from './components/SplitTooltip'; +export type {SplitTooltipProps, SplitTooltipRenderProps} from './components/SplitTooltip'; export type { ChartKitLang, diff --git a/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx b/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx index f1df9a14..ed177471 100644 --- a/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx +++ b/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx @@ -78,4 +78,23 @@ describe('Split tooltip visual tests', () => { await renderWaiter.rendered; await expect(screen.getByTestId(CHART_TEST_STORY_DATA_QA)).toMatchScreenshot(); }); + + test('should use viewport orientation instead of container orientation', async () => { + await page.viewport(600, 280); + const renderWaiter = createRenderWaiter(1); + const screen = await render( + , + {providers: {theme: 'dark'}}, + ); + await renderWaiter.rendered; + + await expect + .element(screen.container.querySelector('.SplitPane')) + .toHaveClass('vertical'); + }); }); diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.test.ts b/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.test.ts new file mode 100644 index 00000000..fc0a9c52 --- /dev/null +++ b/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.test.ts @@ -0,0 +1,12 @@ +import {getViewportSplitTooltipLayout} from './useViewportSplitTooltipLayout'; + +describe('getViewportSplitTooltipLayout', () => { + test('uses vertical layout for a landscape viewport', () => { + expect(getViewportSplitTooltipLayout(640, 320)).toBe('vertical'); + }); + + test('uses horizontal layout for a portrait or square viewport', () => { + expect(getViewportSplitTooltipLayout(320, 640)).toBe('horizontal'); + expect(getViewportSplitTooltipLayout(320, 320)).toBe('horizontal'); + }); +}); diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.ts b/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.ts new file mode 100644 index 00000000..cb3bd8b7 --- /dev/null +++ b/src/plugins/gravity-charts/renderer/withSplitPane/useViewportSplitTooltipLayout.ts @@ -0,0 +1,40 @@ +import React from 'react'; + +import type {SplitTooltipLayout} from '../../../../components/SplitTooltip'; +import {IS_WINDOW_AVAILABLE} from '../../../../constants'; + +export function getViewportSplitTooltipLayout(width: number, height: number): SplitTooltipLayout { + return width > height ? 'vertical' : 'horizontal'; +} + +function getCurrentViewportLayout(): SplitTooltipLayout { + if (!IS_WINDOW_AVAILABLE) { + return 'horizontal'; + } + + return getViewportSplitTooltipLayout(window.innerWidth, window.innerHeight); +} + +export function useViewportSplitTooltipLayout() { + const [layout, setLayout] = React.useState(getCurrentViewportLayout); + + React.useLayoutEffect(() => { + if (!IS_WINDOW_AVAILABLE) { + return undefined; + } + + const handleViewportChange = () => { + setLayout(getCurrentViewportLayout()); + }; + + window.addEventListener('resize', handleViewportChange); + window.addEventListener('orientationchange', handleViewportChange); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('orientationchange', handleViewportChange); + }; + }, []); + + return layout; +} diff --git a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx index 822a0049..8360be22 100644 --- a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx +++ b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx @@ -9,6 +9,7 @@ import {SplitTooltip} from '../../../../components/SplitTooltip'; import {TooltipContent} from './TooltipContent'; import type {TooltipContentRef} from './TooltipContent'; +import {useViewportSplitTooltipLayout} from './useViewportSplitTooltipLayout'; type WithSplitPaneProps = {}; @@ -21,6 +22,7 @@ const SplitPaneContent = (props: ChartProps & {ChartComponent: typeof Chart}) => const chartRef = React.useRef(null); const tooltipRef = React.useRef(null); const [tooltipVisible, setTooltipVisible] = React.useState(false); + const layout = useViewportSplitTooltipLayout(); const headerFormat = React.useMemo(() => { return ( @@ -65,6 +67,7 @@ const SplitPaneContent = (props: ChartProps & {ChartComponent: typeof Chart}) => return ( chartRef.current?.reflow()} renderMainPane={() => ( From 24a3c16f51784ee59931c13a224ab67fc4936d28 Mon Sep 17 00:00:00 2001 From: Evgenii Alaev Date: Thu, 30 Jul 2026 22:39:42 +0200 Subject: [PATCH 4/5] test: group SplitTooltip tests --- .../SplitTooltip/{ => __tests__}/SplitTooltip.test.ts | 5 ++--- .../{ => __tests__}/SplitTooltip.visual.test.tsx | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) rename src/components/SplitTooltip/{ => __tests__}/SplitTooltip.test.ts (97%) rename src/components/SplitTooltip/{ => __tests__}/SplitTooltip.visual.test.tsx (96%) diff --git a/src/components/SplitTooltip/SplitTooltip.test.ts b/src/components/SplitTooltip/__tests__/SplitTooltip.test.ts similarity index 97% rename from src/components/SplitTooltip/SplitTooltip.test.ts rename to src/components/SplitTooltip/__tests__/SplitTooltip.test.ts index af86c6a7..67018c4c 100644 --- a/src/components/SplitTooltip/SplitTooltip.test.ts +++ b/src/components/SplitTooltip/__tests__/SplitTooltip.test.ts @@ -1,12 +1,11 @@ -import {SplitLayout} from '../SplitPane'; - +import {SplitLayout} from '../../SplitPane'; import { SPLIT_TOOLTIP_RESIZER_SIZE, SplitTooltipLayout, getSplitTooltipLayout, getSplitTooltipMainPaneSize, getSplitTooltipSizeLimits, -} from './SplitTooltip'; +} from '../SplitTooltip'; describe('SplitTooltip', () => { test('exposes layout values as part of the public component API', () => { diff --git a/src/components/SplitTooltip/SplitTooltip.visual.test.tsx b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx similarity index 96% rename from src/components/SplitTooltip/SplitTooltip.visual.test.tsx rename to src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx index 272bcde0..79af6f37 100644 --- a/src/components/SplitTooltip/SplitTooltip.visual.test.tsx +++ b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import {render} from '../../../test-utils/utils.js'; -import {SplitLayout} from '../SplitPane/index.js'; - -import {SplitTooltip} from './SplitTooltip.js'; -import type {SplitTooltipLayout} from './SplitTooltip.js'; +import {render} from '../../../../test-utils/utils.js'; +import {SplitLayout} from '../../SplitPane/index.js'; +import {SplitTooltip} from '../SplitTooltip.js'; +import type {SplitTooltipLayout} from '../SplitTooltip.js'; type TestCaseProps = { containerHeight: number; From 6c08d9481c85667c375cabeff5810ba4dd298d64 Mon Sep 17 00:00:00 2001 From: Evgenii Alaev Date: Fri, 31 Jul 2026 12:39:39 +0200 Subject: [PATCH 5/5] feat: make split tooltip ratio configurable --- src/components/SplitTooltip/SplitTooltip.tsx | 12 +++++-- .../__tests__/SplitTooltip.visual.test.tsx | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/components/SplitTooltip/SplitTooltip.tsx b/src/components/SplitTooltip/SplitTooltip.tsx index 7866e005..a6bac77b 100644 --- a/src/components/SplitTooltip/SplitTooltip.tsx +++ b/src/components/SplitTooltip/SplitTooltip.tsx @@ -24,6 +24,8 @@ export interface SplitTooltipProps { * vertical when width is greater than height, horizontal otherwise. */ layout?: SplitTooltipLayout; + /** The share of the container width occupied by the main pane in vertical layout. Defaults to `0.6`. */ + mainPaneRatio?: number; /** Additional styles for the main pane. */ mainPaneStyle?: React.CSSProperties; /** Called after initialization and whenever the main pane size or layout changes. */ @@ -60,15 +62,17 @@ export function getSplitTooltipMainPaneSize({ containerHeight, containerWidth, layout, + mainPaneRatio = SPLIT_TOOLTIP_MAIN_PANE_RATIO, tooltipHeight, }: { containerHeight: number; containerWidth: number; layout: SplitTooltipLayout; + mainPaneRatio?: number; tooltipHeight: number; }) { if (layout === SplitLayout.VERTICAL) { - return containerWidth * SPLIT_TOOLTIP_MAIN_PANE_RATIO; + return containerWidth * mainPaneRatio; } return Math.max(0, containerHeight - SPLIT_TOOLTIP_RESIZER_SIZE - tooltipHeight); @@ -92,6 +96,7 @@ type SplitTooltipDimensions = { containerHeight: number; containerWidth: number; layout: SplitTooltipLayout; + mainPaneRatio: number; tooltipHeight: number; }; @@ -134,6 +139,7 @@ function SplitTooltipContent(props: SplitTooltipContentProps) { renderMainPane, renderTooltip, layout: layoutProp, + mainPaneRatio = SPLIT_TOOLTIP_MAIN_PANE_RATIO, resizerVisible, onMainPaneSizeChange, style, @@ -148,6 +154,7 @@ function SplitTooltipContent(props: SplitTooltipContentProps) { containerHeight, containerWidth, layout, + mainPaneRatio, tooltipHeight: 0, }), ); @@ -156,9 +163,10 @@ function SplitTooltipContent(props: SplitTooltipContentProps) { containerHeight, containerWidth, layout, + mainPaneRatio, tooltipHeight, }), - [containerHeight, containerWidth, layout, tooltipHeight], + [containerHeight, containerWidth, layout, mainPaneRatio, tooltipHeight], ); const previousDimensionsRef = React.useRef(dimensions); const lastNotificationRef = React.useRef<{layout: SplitTooltipLayout; size: number} | null>( diff --git a/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx index 79af6f37..746de604 100644 --- a/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx +++ b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx @@ -9,24 +9,26 @@ type TestCaseProps = { containerHeight: number; containerWidth: number; layout?: SplitTooltipLayout; + mainPaneRatio?: number; + onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; resizerVisible?: boolean; tooltipHeight: number; - onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; }; function TestCase({ containerHeight, containerWidth, layout, + mainPaneRatio, + onMainPaneSizeChange, resizerVisible, tooltipHeight, - onMainPaneSizeChange, }: TestCaseProps) { return (
(
{`${actualLayout}:${size}`}
@@ -36,6 +38,7 @@ function TestCase({ {`${actualLayout}:${size}`}
)} + resizerVisible={resizerVisible} /> ); @@ -73,6 +76,32 @@ describe('SplitTooltip behavior', () => { .toHaveTextContent('horizontal:336'); }); + test('uses custom main pane ratio', async () => { + const screen = await render( + , + ); + + await expect.element(screen.getByTestId('main-pane')).toHaveTextContent('vertical:300'); + + await screen.rerender( + , + ); + + await expect.element(screen.getByTestId('main-pane')).toHaveTextContent('vertical:200'); + }); + test('recalculates size and reports only distinct size-layout pairs', async () => { const onMainPaneSizeChange = vi.fn(); const renderTestCase = (props?: Partial) => (