diff --git a/src/components/SplitTooltip/SplitTooltip.tsx b/src/components/SplitTooltip/SplitTooltip.tsx new file mode 100644 index 00000000..a6bac77b --- /dev/null +++ b/src/components/SplitTooltip/SplitTooltip.tsx @@ -0,0 +1,275 @@ +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 {SplitLayout as SplitTooltipLayout}; +export type SplitTooltipLayout = SplitLayoutType; + +export interface SplitTooltipRenderProps { + /** The active pane layout. */ + layout: SplitTooltipLayout; + /** The current size of the main pane in pixels. */ + size: number; +} + +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; + /** 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. */ + 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; + /** Additional styles for the tooltip pane. */ + 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, + layout?: SplitTooltipLayout, +): SplitTooltipLayout { + return layout ?? (width > height ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL); +} + +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 * mainPaneRatio; + } + + 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, + }; +} + +type SplitTooltipDimensions = { + containerHeight: number; + containerWidth: number; + layout: SplitTooltipLayout; + mainPaneRatio: number; + 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, + layout: layoutProp, + mainPaneRatio = SPLIT_TOOLTIP_MAIN_PANE_RATIO, + resizerVisible, + onMainPaneSizeChange, + style, + mainPaneStyle, + tooltipPaneStyle, + } = props; + const tooltipRef = React.useRef(null); + const layout = getSplitTooltipLayout(containerWidth, containerHeight, layoutProp); + const [tooltipHeight, setTooltipHeight] = React.useState(0); + const [size, setSize] = React.useState(() => + getSplitTooltipMainPaneSize({ + containerHeight, + containerWidth, + layout, + mainPaneRatio, + tooltipHeight: 0, + }), + ); + const dimensions = React.useMemo( + () => ({ + containerHeight, + containerWidth, + layout, + mainPaneRatio, + tooltipHeight, + }), + [containerHeight, containerWidth, layout, mainPaneRatio, 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; + setTooltipHeight(nextTooltipHeight); + }, []); + + useResizeObserver({ + ref: tooltipRef, + onResize: handleTooltipResize, + }); + + const effectiveSize = getNextMainPaneSize({ + currentSize: size, + nextDimensions: dimensions, + previousDimensions: previousDimensionsRef.current, + }); + + React.useLayoutEffect(() => { + previousDimensionsRef.current = dimensions; + + if (effectiveSize !== size) { + setSize(effectiveSize); + } + }, [dimensions, effectiveSize, size]); + + React.useLayoutEffect(() => { + const previousNotification = lastNotificationRef.current; + + if ( + previousNotification?.size === effectiveSize && + previousNotification.layout === layout + ) { + return undefined; + } + + const callback = onMainPaneSizeChangeRef.current; + if (!callback) { + return undefined; + } + + 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: effectiveSize}; + + 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/__tests__/SplitTooltip.test.ts b/src/components/SplitTooltip/__tests__/SplitTooltip.test.ts new file mode 100644 index 00000000..67018c4c --- /dev/null +++ b/src/components/SplitTooltip/__tests__/SplitTooltip.test.ts @@ -0,0 +1,72 @@ +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); + }); + + test('uses vertical layout in landscape orientation', () => { + 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({ + 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/__tests__/SplitTooltip.visual.test.tsx b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx new file mode 100644 index 00000000..746de604 --- /dev/null +++ b/src/components/SplitTooltip/__tests__/SplitTooltip.visual.test.tsx @@ -0,0 +1,184 @@ +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; + mainPaneRatio?: number; + onMainPaneSizeChange?: (size: number, layout: SplitTooltipLayout) => void; + resizerVisible?: boolean; + tooltipHeight: number; +}; + +function TestCase({ + containerHeight, + containerWidth, + layout, + mainPaneRatio, + onMainPaneSizeChange, + resizerVisible, + tooltipHeight, +}: TestCaseProps) { + return ( +
+ ( +
{`${actualLayout}:${size}`}
+ )} + renderTooltip={({layout: actualLayout, size}) => ( +
+ {`${actualLayout}:${size}`} +
+ )} + resizerVisible={resizerVisible} + /> +
+ ); +} + +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('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) => ( + 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 is false by default and does not hide 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 new file mode 100644 index 00000000..e24ede83 --- /dev/null +++ b/src/components/SplitTooltip/index.ts @@ -0,0 +1,2 @@ +export {SplitTooltip, SplitTooltipLayout} from './SplitTooltip'; +export type {SplitTooltipProps, SplitTooltipRenderProps} from './SplitTooltip'; diff --git a/src/index.ts b/src/index.ts index 70e58154..3d17fd9f 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, SplitTooltipLayout} 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 aceb4178..8e840b1f 100644 Binary files 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 and 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 differ 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 64c318a6..8085ee51 100644 Binary files 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 and 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 differ 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..ed177471 100644 --- a/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx +++ b/src/plugins/gravity-charts/__tests__/SplitTooltip.visual.test.tsx @@ -25,28 +25,76 @@ 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(); }); + + 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/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..8360be22 100644 --- a/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx +++ b/src/plugins/gravity-charts/renderer/withSplitPane/withSplitPane.tsx @@ -2,23 +2,14 @@ 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'}; +import {useViewportSplitTooltipLayout} from './useViewportSplitTooltipLayout'; type WithSplitPaneProps = {}; @@ -26,62 +17,12 @@ 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 layout = useViewportSplitTooltipLayout(); const headerFormat = React.useMemo(() => { return ( @@ -98,7 +39,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 +65,25 @@ 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 +92,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 ; }, );