Grafana/UI: Replace Splitter with useSplitter hook and refactor PanelEdit snapping logic to useSnappingSplitter hook (#82895)
* Hook refactor * Update * Test * Update * Both directions work * fixes * refactoring * Update * Update * update * Remove consolo.log * Update * Fix
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import { Meta, Preview, ArgTypes } from '@storybook/blocks';
|
||||
import { Box, Splitter, Text } from '@grafana/ui';
|
||||
|
||||
<Meta title="MDX|Splitter" component={Splitter} />
|
||||
|
||||
# Splitter
|
||||
|
||||
The splitter creates two resizable panes, either horizontally or vertically.
|
||||
|
||||
<Preview>
|
||||
<div style={{ display: 'flex', width: '900px', height: '700px' }}>
|
||||
<Splitter direction="row" initialSize={0.7}>
|
||||
<Box display="flex" grow={1} backgroundColor="secondary" padding={2}>
|
||||
<Text color="primary">Primary</Text>
|
||||
</Box>
|
||||
<Box display="flex" grow={1} backgroundColor="secondary" padding={2}>
|
||||
<Text color="primary">Secondary</Text>
|
||||
</Box>
|
||||
</Splitter>
|
||||
</div>
|
||||
</Preview>
|
||||
|
||||
<ArgTypes of={Splitter} />
|
||||
@@ -1,55 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Splitter, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
|
||||
|
||||
import mdx from './Splitter.mdx';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'General/Layout/Splitter',
|
||||
component: Splitter,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
controls: {
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialSize: { control: { type: 'number', min: 0.1, max: 1 } },
|
||||
},
|
||||
};
|
||||
|
||||
export const Basic: StoryFn = (args) => {
|
||||
const theme = useTheme2();
|
||||
const paneStyles = css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
background: theme.colors.background.primary,
|
||||
padding: theme.spacing(2),
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardStoryCanvas>
|
||||
<div style={{ display: 'flex', width: '700px', height: '500px' }}>
|
||||
<Splitter {...args}>
|
||||
<div className={paneStyles}>Primary</div>
|
||||
<div className={paneStyles}>Secondary</div>
|
||||
</Splitter>
|
||||
</div>
|
||||
</DashboardStoryCanvas>
|
||||
);
|
||||
};
|
||||
|
||||
Basic.args = {
|
||||
direction: 'row',
|
||||
dragPosition: 'middle',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Meta, ArgTypes } from '@storybook/blocks';
|
||||
import { Box, useSplitter, Text } from '@grafana/ui';
|
||||
|
||||
# useSplitter
|
||||
|
||||
The splitter creates two resizable panes, either horizontally or vertically.
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { useSplitter } from '@grafana/ui';
|
||||
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
|
||||
direction: 'row',
|
||||
initialSize: 0.5,
|
||||
dragPosition: 'end',
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps}>
|
||||
<Box display="flex" grow={1} backgroundColor="primary" padding={2}>
|
||||
Primary
|
||||
</Box>
|
||||
</div>
|
||||
<div {...splitterProps} />
|
||||
<div {...secondaryProps}>
|
||||
<Box display="flex" grow={1} backgroundColor="primary" padding={2}>
|
||||
Secondary
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Box } from '@grafana/ui';
|
||||
|
||||
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
|
||||
|
||||
import { UseSplitterOptions, useSplitter } from './useSplitter';
|
||||
import mdx from './useSplitter.mdx';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'General/Layout/useSplitter',
|
||||
parameters: {
|
||||
docs: { page: mdx },
|
||||
controls: {
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
initialSize: { control: { type: 'number', min: 0.1, max: 1 } },
|
||||
direction: { control: { type: 'radio' }, options: ['row', 'column'] },
|
||||
dragPosition: { control: { type: 'radio' }, options: ['start', 'middle', 'end'] },
|
||||
hasSecondPane: { type: 'boolean', options: [true, false] },
|
||||
},
|
||||
};
|
||||
|
||||
interface StoryOptions extends UseSplitterOptions {
|
||||
hasSecondPane: boolean;
|
||||
}
|
||||
|
||||
export const Basic: StoryFn<StoryOptions> = (options) => {
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!options.hasSecondPane) {
|
||||
primaryProps.style.flexGrow = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardStoryCanvas>
|
||||
<div style={{ display: 'flex', width: '700px', height: '500px' }}>
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps}>
|
||||
<Box display="flex" grow={1} backgroundColor="primary" padding={2}>
|
||||
Primary
|
||||
</Box>
|
||||
</div>
|
||||
{options.hasSecondPane && (
|
||||
<>
|
||||
<div {...splitterProps} />
|
||||
<div {...secondaryProps}>
|
||||
<Box display="flex" grow={1} backgroundColor="primary" padding={2}>
|
||||
Secondary
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardStoryCanvas>
|
||||
);
|
||||
};
|
||||
|
||||
Basic.args = {
|
||||
direction: 'row',
|
||||
dragPosition: 'middle',
|
||||
hasSecondPane: true,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
+56
-139
@@ -7,137 +7,19 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { DragHandlePosition, getDragStyles } from '../DragHandle/DragHandle';
|
||||
|
||||
export interface Props {
|
||||
export interface UseSplitterOptions {
|
||||
/**
|
||||
* The initial size of the primary pane between 0-1, defaults to 0.5
|
||||
*/
|
||||
initialSize?: number;
|
||||
direction?: 'row' | 'column';
|
||||
direction: 'row' | 'column';
|
||||
dragPosition?: DragHandlePosition;
|
||||
primaryPaneStyles?: React.CSSProperties;
|
||||
secondaryPaneStyles?: React.CSSProperties;
|
||||
/**
|
||||
* Called when ever the size of the primary pane changes
|
||||
* @param flexSize (float from 0-1)
|
||||
*/
|
||||
onSizeChanged?: (flexSize: number, pixelSize: number) => void;
|
||||
onResizing?: (flexSize: number, pixelSize: number) => void;
|
||||
children: [React.ReactNode, React.ReactNode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits two children into two resizable panes
|
||||
* @alpha
|
||||
*/
|
||||
export function Splitter(props: Props) {
|
||||
const {
|
||||
direction = 'row',
|
||||
initialSize = 0.5,
|
||||
primaryPaneStyles,
|
||||
secondaryPaneStyles,
|
||||
onSizeChanged,
|
||||
onResizing,
|
||||
dragPosition = 'middle',
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const { containerRef, firstPaneRef, minDimProp, splitterProps, secondPaneRef } = useSplitter(
|
||||
direction,
|
||||
onSizeChanged,
|
||||
onResizing
|
||||
);
|
||||
|
||||
const kids = React.Children.toArray(children);
|
||||
const styles = useStyles2(getStyles, direction);
|
||||
const dragStyles = useStyles2(getDragStyles, dragPosition);
|
||||
const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical;
|
||||
const id = useId();
|
||||
|
||||
const secondAvailable = kids.length === 2;
|
||||
const visibilitySecond = secondAvailable ? 'visible' : 'hidden';
|
||||
let firstChildSize = initialSize;
|
||||
|
||||
// If second child is missing let first child have all the space
|
||||
if (!children[1]) {
|
||||
firstChildSize = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.container}>
|
||||
<div
|
||||
ref={firstPaneRef}
|
||||
className={styles.panel}
|
||||
style={{
|
||||
flexGrow: clamp(firstChildSize, 0, 1),
|
||||
[minDimProp]: 'min-content',
|
||||
...primaryPaneStyles,
|
||||
}}
|
||||
id={`start-panel-${id}`}
|
||||
>
|
||||
{kids[0]}
|
||||
</div>
|
||||
|
||||
{kids[1] && (
|
||||
<>
|
||||
<div
|
||||
className={dragHandleStyle}
|
||||
{...splitterProps}
|
||||
role="separator"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={initialSize * 100}
|
||||
aria-controls={`start-panel-${id}`}
|
||||
aria-label="Pane resize widget"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
></div>
|
||||
|
||||
<div
|
||||
ref={secondPaneRef}
|
||||
className={styles.panel}
|
||||
style={{
|
||||
flexGrow: clamp(1 - initialSize, 0, 1),
|
||||
[minDimProp]: 'min-content',
|
||||
visibility: `${visibilitySecond}`,
|
||||
...secondaryPaneStyles,
|
||||
}}
|
||||
id={`end-panel-${id}`}
|
||||
>
|
||||
{kids[1]}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, direction: Props['direction']) {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: direction === 'row' ? 'row' : 'column',
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
panel: css({ display: 'flex', position: 'relative', flexBasis: 0 }),
|
||||
dragEdge: {
|
||||
second: css({
|
||||
top: 0,
|
||||
left: theme.spacing(-1),
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
zIndex: theme.zIndex.modal,
|
||||
}),
|
||||
first: css({
|
||||
top: 0,
|
||||
left: theme.spacing(-1),
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
zIndex: theme.zIndex.modal,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PIXELS_PER_MS = 0.3 as const;
|
||||
@@ -159,11 +41,9 @@ const propsForDirection = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
function useSplitter(
|
||||
direction: 'row' | 'column',
|
||||
onSizeChanged: Props['onSizeChanged'],
|
||||
onResizing: Props['onResizing']
|
||||
) {
|
||||
export function useSplitter(options: UseSplitterOptions) {
|
||||
const { direction, initialSize = 0.5, dragPosition = 'middle', onResizing, onSizeChanged } = options;
|
||||
|
||||
const handleSize = 16;
|
||||
const splitterRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstPaneRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -171,7 +51,6 @@ function useSplitter(
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerSize = useRef<number | null>(null);
|
||||
const primarySizeRef = useRef<'1fr' | number>('1fr');
|
||||
|
||||
const firstPaneMeasurements = useRef<MeasureResult | undefined>(undefined);
|
||||
const savedPos = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -197,11 +76,7 @@ function useSplitter(
|
||||
const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp];
|
||||
const newDims = measureElement(firstPaneRef.current);
|
||||
|
||||
splitterRef.current!.ariaValueNow = `${clamp(
|
||||
((curSize - newDims[minDimProp]) / (newDims[maxDimProp] - newDims[minDimProp])) * 100,
|
||||
0,
|
||||
100
|
||||
)}`;
|
||||
splitterRef.current!.ariaValueNow = ariaValue(curSize, newDims[minDimProp], newDims[maxDimProp]);
|
||||
}
|
||||
},
|
||||
500,
|
||||
@@ -239,10 +114,8 @@ function useSplitter(
|
||||
|
||||
firstPaneRef.current!.style.flexGrow = `${newFlex}`;
|
||||
secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`;
|
||||
splitterRef.current!.ariaValueNow = ariaValue(newSize, dims[minDimProp], dims[maxDimProp]);
|
||||
|
||||
const ariaValueNow = ariaValue(newSize, dims[minDimProp], dims[maxDimProp] - dims[minDimProp]);
|
||||
|
||||
splitterRef.current!.ariaValueNow = `${ariaValueNow}`;
|
||||
onResizing?.(newFlex, newSize);
|
||||
}
|
||||
},
|
||||
@@ -406,7 +279,7 @@ function useSplitter(
|
||||
const dim = measureElement(firstPaneRef.current);
|
||||
firstPaneMeasurements.current = dim;
|
||||
primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp];
|
||||
splitterRef.current!.ariaValueNow = `${((primarySizeRef.current - dim[minDimProp]) / (dim[maxDimProp] - dim[minDimProp])) * 100}`;
|
||||
splitterRef.current!.ariaValueNow = `${ariaValue(primarySizeRef.current, dim[minDimProp], dim[maxDimProp])}`;
|
||||
}, [maxDimProp, measurementProp, minDimProp]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
@@ -421,10 +294,32 @@ function useSplitter(
|
||||
}
|
||||
}, [onSizeChanged]);
|
||||
|
||||
const styles = useStyles2(getStyles, direction);
|
||||
const dragStyles = useStyles2(getDragStyles, dragPosition);
|
||||
const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical;
|
||||
const id = useId();
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
firstPaneRef,
|
||||
minDimProp,
|
||||
containerProps: {
|
||||
ref: containerRef,
|
||||
className: styles.container,
|
||||
},
|
||||
primaryProps: {
|
||||
ref: firstPaneRef,
|
||||
className: styles.panel,
|
||||
style: {
|
||||
[minDimProp]: 'min-content',
|
||||
flexGrow: clamp(initialSize ?? 0.5, 0, 1),
|
||||
},
|
||||
},
|
||||
secondaryProps: {
|
||||
ref: secondPaneRef,
|
||||
className: styles.panel,
|
||||
style: {
|
||||
flexGrow: clamp(1 - initialSize, 0, 1),
|
||||
[minDimProp]: 'min-content',
|
||||
},
|
||||
},
|
||||
splitterProps: {
|
||||
onPointerUp,
|
||||
onPointerDown,
|
||||
@@ -435,8 +330,15 @@ function useSplitter(
|
||||
onBlur,
|
||||
ref: splitterRef,
|
||||
style: { [measurementProp]: `${handleSize}px` },
|
||||
role: 'separator',
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
'aria-valuenow': initialSize * 100,
|
||||
'aria-controls': `start-panel-${id}`,
|
||||
'aria-label': 'Pane resize widget',
|
||||
tabIndex: 0,
|
||||
className: dragHandleStyle,
|
||||
},
|
||||
secondPaneRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,8 +358,10 @@ function measureElement<T extends HTMLElement>(ref: T): MeasureResult {
|
||||
const savedWidth = ref.style.width;
|
||||
const savedHeight = ref.style.height;
|
||||
const savedFlex = ref.style.flexGrow;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
ref.style.flexGrow = '0';
|
||||
|
||||
const { width: minWidth, height: minHeight } = ref.getBoundingClientRect();
|
||||
|
||||
ref.style.flexGrow = '100';
|
||||
@@ -491,3 +395,16 @@ function useResizeObserver(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, direction: UseSplitterOptions['direction']) {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: direction === 'row' ? 'row' : 'column',
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
panel: css({ display: 'flex', position: 'relative', flexBasis: 0 }),
|
||||
};
|
||||
}
|
||||
@@ -264,8 +264,8 @@ export { Avatar } from './UsersIndicator/Avatar';
|
||||
// Export this until we've figured out a good approach to inline form styles.
|
||||
export { InlineFormLabel } from './FormLabel/FormLabel';
|
||||
export { Divider } from './Divider/Divider';
|
||||
export { getDragStyles } from './DragHandle/DragHandle';
|
||||
export { Splitter } from './Splitter/Splitter';
|
||||
export { getDragStyles, type DragHandlePosition } from './DragHandle/DragHandle';
|
||||
export { useSplitter } from './Splitter/useSplitter';
|
||||
|
||||
export { LayoutItemContext, type LayoutItemContextProps } from './Layout/LayoutItemContext';
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface PanelEditorState extends SceneObjectState {
|
||||
isDirty?: boolean;
|
||||
panelId: number;
|
||||
optionsPane: PanelOptionsPane;
|
||||
optionsCollapsed?: boolean;
|
||||
optionsPaneSize: number;
|
||||
dataPane?: PanelDataPane;
|
||||
vizManager: VizPanelManager;
|
||||
}
|
||||
@@ -102,56 +100,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
sourcePanel!.parent.setState({ body: this.state.vizManager.state.panel.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
public toggleOptionsPane() {
|
||||
this.setState({ optionsCollapsed: !this.state.optionsCollapsed, optionsPaneSize: OPTIONS_PANE_FLEX_DEFAULT });
|
||||
}
|
||||
|
||||
public onOptionsPaneResizing = (flexSize: number, pixelSize: number) => {
|
||||
if (flexSize <= 0 && pixelSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsPixelSize = (pixelSize / flexSize) * (1 - flexSize);
|
||||
|
||||
if (this.state.optionsCollapsed && optionsPixelSize > OPTIONS_PANE_PIXELS_MIN) {
|
||||
this.setState({ optionsCollapsed: false });
|
||||
}
|
||||
|
||||
if (!this.state.optionsCollapsed && optionsPixelSize < OPTIONS_PANE_PIXELS_MIN) {
|
||||
this.setState({ optionsCollapsed: true });
|
||||
}
|
||||
};
|
||||
|
||||
public onOptionsPaneSizeChanged = (flexSize: number, pixelSize: number) => {
|
||||
if (flexSize <= 0 && pixelSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsPaneSize = 1 - flexSize;
|
||||
const isSnappedClosed = this.state.optionsPaneSize === 0;
|
||||
const fullWidth = pixelSize / flexSize;
|
||||
const snapWidth = OPTIONS_PANE_PIXELS_SNAP / fullWidth;
|
||||
|
||||
if (this.state.optionsCollapsed) {
|
||||
if (isSnappedClosed) {
|
||||
this.setState({
|
||||
optionsPaneSize: Math.max(optionsPaneSize, snapWidth),
|
||||
optionsCollapsed: false,
|
||||
});
|
||||
} else {
|
||||
this.setState({ optionsPaneSize: 0 });
|
||||
}
|
||||
} else if (isSnappedClosed) {
|
||||
this.setState({ optionsPaneSize: optionsPaneSize });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const OPTIONS_PANE_PIXELS_MIN = 300;
|
||||
export const OPTIONS_PANE_PIXELS_SNAP = 400;
|
||||
export const OPTIONS_PANE_FLEX_DEFAULT = 0.25;
|
||||
|
||||
export function buildPanelEditScene(panel: VizPanel): PanelEditor {
|
||||
const panelClone = panel.clone();
|
||||
const vizPanelMgr = new VizPanelManager(panelClone);
|
||||
@@ -160,6 +110,5 @@ export function buildPanelEditScene(panel: VizPanel): PanelEditor {
|
||||
panelId: getPanelIdForVizPanel(panel),
|
||||
optionsPane: new PanelOptionsPane({}),
|
||||
vizManager: vizPanelMgr,
|
||||
optionsPaneSize: OPTIONS_PANE_FLEX_DEFAULT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,56 +1,109 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { Splitter, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { NavToolbarActions } from '../scene/NavToolbarActions';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { PanelEditor } from './PanelEditor';
|
||||
import { useSnappingSplitter } from './splitter/useSnappingSplitter';
|
||||
|
||||
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { optionsPane, vizManager, dataPane, optionsPaneSize } = model.useState();
|
||||
const { controls } = dashboard.useState();
|
||||
const { optionsPane } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const [vizPaneStyles, optionsPaneStyles] = useMemo(() => {
|
||||
if (optionsPaneSize > 0) {
|
||||
return [{ flexGrow: 1 - optionsPaneSize }, { minWidth: 'unset', overflow: 'hidden', flexGrow: optionsPaneSize }];
|
||||
} else {
|
||||
return [{ flexGrow: 1 }, { minWidth: 'unset', flexGrow: 0 }];
|
||||
}
|
||||
}, [optionsPaneSize]);
|
||||
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
|
||||
useSnappingSplitter({
|
||||
direction: 'row',
|
||||
dragPosition: 'end',
|
||||
initialSize: 0.75,
|
||||
paneOptions: {
|
||||
collapseBelowPixels: 250,
|
||||
snapOpenToPixels: 400,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavToolbarActions dashboard={dashboard} />
|
||||
<Splitter
|
||||
direction="row"
|
||||
dragPosition="end"
|
||||
initialSize={0.75}
|
||||
primaryPaneStyles={vizPaneStyles}
|
||||
secondaryPaneStyles={optionsPaneStyles}
|
||||
onResizing={model.onOptionsPaneResizing}
|
||||
onSizeChanged={model.onOptionsPaneSizeChanged}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.canvasContent}>
|
||||
{controls && <controls.Component model={controls} />}
|
||||
<Splitter
|
||||
direction="column"
|
||||
primaryPaneStyles={{ minHeight: 0, paddingBottom: !dataPane ? 16 : 0 }}
|
||||
secondaryPaneStyles={{ minHeight: 0, overflow: 'hidden' }}
|
||||
dragPosition="start"
|
||||
>
|
||||
<vizManager.Component model={vizManager} />
|
||||
{dataPane && <dataPane.Component model={dataPane} />}
|
||||
</Splitter>
|
||||
</div>
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps} className={cx(primaryProps.className, styles.body)}>
|
||||
<VizAndDataPane model={model} />
|
||||
</div>
|
||||
{optionsPane && <optionsPane.Component model={optionsPane} />}
|
||||
</Splitter>
|
||||
<div {...splitterProps} />
|
||||
<div {...secondaryProps} className={cx(secondaryProps.className, styles.optionsPane)}>
|
||||
{splitterState.collapsed && (
|
||||
<div className={styles.expandOptionsWrapper}>
|
||||
<ToolbarButton
|
||||
tooltip={'Open options pane'}
|
||||
icon={'arrow-to-right'}
|
||||
onClick={onToggleCollapse}
|
||||
variant="canvas"
|
||||
className={styles.rotate180}
|
||||
aria-label={'Open options pane'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!splitterState.collapsed && <optionsPane.Component model={optionsPane} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { vizManager, dataPane } = model.useState();
|
||||
const { controls } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
|
||||
useSnappingSplitter({
|
||||
direction: 'column',
|
||||
dragPosition: 'start',
|
||||
initialSize: 0.5,
|
||||
paneOptions: {
|
||||
collapseBelowPixels: 150,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dataPane) {
|
||||
primaryProps.style.flexGrow = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls && <controls.Component model={controls} />}
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps}>
|
||||
<vizManager.Component model={vizManager} />
|
||||
</div>
|
||||
{dataPane && (
|
||||
<>
|
||||
<div {...splitterProps} />
|
||||
<div {...secondaryProps}>
|
||||
{splitterState.collapsed && (
|
||||
<div className={styles.expandDataPane}>
|
||||
<Button
|
||||
tooltip={'Open query pane'}
|
||||
icon={'arrow-to-right'}
|
||||
onClick={onToggleCollapse}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={styles.openDataPaneButton}
|
||||
aria-label={'Open query pane'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!splitterState.collapsed && <dataPane.Component model={dataPane} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +123,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
label: 'body',
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
gap: '8px',
|
||||
}),
|
||||
@@ -81,5 +134,35 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(2, 0, 2, 2),
|
||||
}),
|
||||
optionsPane: css({
|
||||
flexDirection: 'column',
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
}),
|
||||
expandOptionsWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(2, 1),
|
||||
}),
|
||||
expandDataPane: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: theme.spacing(1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'space-around',
|
||||
}),
|
||||
rotate180: css({
|
||||
rotate: '180deg',
|
||||
}),
|
||||
openDataPaneButton: css({
|
||||
width: theme.spacing(8),
|
||||
justifyContent: 'center',
|
||||
svg: {
|
||||
rotate: '-90deg',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,47 +39,18 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
||||
this.setState({ listMode });
|
||||
};
|
||||
|
||||
onCollapsePane = () => {
|
||||
const editor = sceneGraph.getAncestor(this, PanelEditor);
|
||||
editor.toggleOptionsPane();
|
||||
};
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
|
||||
const { isVizPickerOpen, searchQuery, listMode } = model.useState();
|
||||
const editor = sceneGraph.getAncestor(model, PanelEditor);
|
||||
const { optionsCollapsed, vizManager } = editor.useState();
|
||||
const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager;
|
||||
const { pluginId } = vizManager.state.panel.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (optionsCollapsed) {
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<div className={styles.top}>
|
||||
<ToolbarButton
|
||||
tooltip={'Open options pane'}
|
||||
icon={'arrow-to-right'}
|
||||
onClick={model.onCollapsePane}
|
||||
variant="canvas"
|
||||
className={styles.rotateIcon}
|
||||
data-testid={selectors.components.PanelEditor.toggleVizOptions}
|
||||
aria-label={'Open options pane'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<>
|
||||
{!isVizPickerOpen && (
|
||||
<>
|
||||
<div className={styles.top}>
|
||||
<VisualizationButton
|
||||
pluginId={pluginId}
|
||||
onOpen={model.onToggleVizPicker}
|
||||
isOpen={isVizPickerOpen}
|
||||
onTogglePane={model.onCollapsePane}
|
||||
/>
|
||||
<VisualizationButton pluginId={pluginId} onOpen={model.onToggleVizPicker} />
|
||||
<FilterInput
|
||||
className={styles.searchOptions}
|
||||
value={searchQuery}
|
||||
@@ -93,20 +64,13 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
||||
</>
|
||||
)}
|
||||
{isVizPickerOpen && <PanelVizTypePicker vizManager={vizManager} onChange={model.onToggleVizPicker} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
pane: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: '1',
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
}),
|
||||
top: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -137,11 +101,9 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
interface VisualizationButtonProps {
|
||||
pluginId: string;
|
||||
onOpen: () => void;
|
||||
isOpen?: boolean;
|
||||
onTogglePane?: () => void;
|
||||
}
|
||||
|
||||
export function VisualizationButton({ pluginId, onOpen, isOpen, onTogglePane }: VisualizationButtonProps) {
|
||||
export function VisualizationButton({ pluginId, onOpen }: VisualizationButtonProps) {
|
||||
const styles = useStyles2(getVizButtonStyles);
|
||||
const pluginMeta = useMemo(() => getAllPanelPluginMeta().filter((p) => p.id === pluginId)[0], [pluginId]);
|
||||
|
||||
@@ -160,14 +122,6 @@ export function VisualizationButton({ pluginId, onOpen, isOpen, onTogglePane }:
|
||||
>
|
||||
{pluginMeta.name}
|
||||
</ToolbarButton>
|
||||
{/* <ToolbarButton
|
||||
tooltip={'Close options pane'}
|
||||
icon={'arrow-to-right'}
|
||||
onClick={onTogglePane}
|
||||
variant="canvas"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizOptions}
|
||||
aria-label={'Close options pane'}
|
||||
/> */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { DragHandlePosition, useSplitter } from '@grafana/ui';
|
||||
|
||||
export interface UseSnappingSplitterOptions {
|
||||
/**
|
||||
* The initial size of the primary pane between 0-1, defaults to 0.5
|
||||
*/
|
||||
initialSize?: number;
|
||||
direction: 'row' | 'column';
|
||||
dragPosition?: DragHandlePosition;
|
||||
paneOptions: PaneOptions;
|
||||
}
|
||||
|
||||
interface PaneOptions {
|
||||
collapseBelowPixels: number;
|
||||
snapOpenToPixels?: number;
|
||||
}
|
||||
|
||||
interface PaneState {
|
||||
collapsed: boolean;
|
||||
snapSize?: number;
|
||||
}
|
||||
|
||||
export function useSnappingSplitter(options: UseSnappingSplitterOptions) {
|
||||
const { paneOptions } = options;
|
||||
|
||||
const [state, setState] = React.useState<PaneState>({ collapsed: false });
|
||||
|
||||
const onResizing = useCallback(
|
||||
(flexSize: number, pixelSize: number) => {
|
||||
if (flexSize <= 0 && pixelSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsPixelSize = (pixelSize / flexSize) * (1 - flexSize);
|
||||
|
||||
if (state.collapsed && optionsPixelSize > paneOptions.collapseBelowPixels) {
|
||||
setState({ collapsed: false });
|
||||
}
|
||||
|
||||
if (!state.collapsed && optionsPixelSize < paneOptions.collapseBelowPixels) {
|
||||
setState({ collapsed: true });
|
||||
}
|
||||
},
|
||||
[state, paneOptions.collapseBelowPixels]
|
||||
);
|
||||
|
||||
const onSizeChanged = useCallback(
|
||||
(flexSize: number, pixelSize: number) => {
|
||||
if (flexSize <= 0 && pixelSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSecondPaneSize = 1 - flexSize;
|
||||
const isSnappedClosed = state.snapSize === 0;
|
||||
const sizeOfBothPanes = pixelSize / flexSize;
|
||||
const snapOpenToPixels = paneOptions.snapOpenToPixels ?? sizeOfBothPanes / 2;
|
||||
const snapSize = snapOpenToPixels / sizeOfBothPanes;
|
||||
|
||||
if (state.collapsed) {
|
||||
if (isSnappedClosed) {
|
||||
setState({ snapSize: Math.max(newSecondPaneSize, snapSize), collapsed: false });
|
||||
} else {
|
||||
setState({ snapSize: 0, collapsed: true });
|
||||
}
|
||||
} else if (isSnappedClosed) {
|
||||
setState({ snapSize: newSecondPaneSize, collapsed: false });
|
||||
}
|
||||
},
|
||||
[state, paneOptions.snapOpenToPixels]
|
||||
);
|
||||
|
||||
const onToggleCollapse = useCallback(() => {
|
||||
setState({ collapsed: !state.collapsed });
|
||||
}, [state.collapsed]);
|
||||
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
|
||||
...options,
|
||||
onResizing,
|
||||
onSizeChanged,
|
||||
});
|
||||
|
||||
// This is to allow resizing it beyond the content dimensions
|
||||
secondaryProps.style.overflow = 'hidden';
|
||||
secondaryProps.style.minWidth = 'unset';
|
||||
secondaryProps.style.minHeight = 'unset';
|
||||
|
||||
if (state.snapSize) {
|
||||
primaryProps.style = {
|
||||
...primaryProps.style,
|
||||
flexGrow: 1 - state.snapSize,
|
||||
};
|
||||
secondaryProps.style.flexGrow = state.snapSize;
|
||||
} else if (state.snapSize === 0) {
|
||||
primaryProps.style.flexGrow = 1;
|
||||
secondaryProps.style.flexGrow = 0;
|
||||
secondaryProps.style.minWidth = 'unset';
|
||||
secondaryProps.style.minHeight = 'unset';
|
||||
secondaryProps.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return { containerProps, primaryProps, secondaryProps, splitterProps, splitterState: state, onToggleCollapse };
|
||||
}
|
||||
Reference in New Issue
Block a user