Grafana/UI: Add new Splitter component (#82357)

* Initial thing working

* Update

* Progress

* Update

* Update

* Simplify a bit more

* minor refacto

* more review fixes

* Update

* review fix

* minor fix

* update
This commit is contained in:
Torkel Ödegaard 2024-02-14 12:45:29 +01:00 committed by GitHub
parent 7694e7bca3
commit fe795417f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 584 additions and 5 deletions

View File

@ -956,6 +956,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
[0, 0, 0, "Unexpected any. Specify a different type.", "19"]
],
"packages/grafana-ui/src/components/Splitter/Splitter.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -2,12 +2,30 @@ import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getDragStyles = (theme: GrafanaTheme2) => {
export type DragHandlePosition = 'middle' | 'start' | 'end';
export const getDragStyles = (theme: GrafanaTheme2, handlePosition?: DragHandlePosition) => {
const position = handlePosition || 'middle';
const baseColor = theme.colors.emphasize(theme.colors.background.secondary, 0.15);
const hoverColor = theme.colors.primary.border;
const clickTargetSize = theme.spacing(2);
const handlebarThickness = 4;
const handlebarWidth = 200;
let verticalOffset = '50%';
let horizontalOffset = '50%';
switch (position) {
case 'start': {
verticalOffset = '0%';
horizontalOffset = '0%';
break;
}
case 'end': {
verticalOffset = '100%';
horizontalOffset = '100%';
break;
}
}
const dragHandleBase = css({
position: 'relative',
@ -16,17 +34,17 @@ export const getDragStyles = (theme: GrafanaTheme2) => {
content: '""',
position: 'absolute',
transition: theme.transitions.create('border-color'),
zIndex: 1,
},
'&:after': {
background: baseColor,
content: '""',
position: 'absolute',
left: '50%',
top: '50%',
transition: theme.transitions.create('background'),
transform: 'translate(-50%, -50%)',
borderRadius: theme.shape.radius.pill,
zIndex: 1,
},
'&:hover': {
@ -50,11 +68,13 @@ export const getDragStyles = (theme: GrafanaTheme2) => {
'&:before': {
borderRight: '1px solid transparent',
height: '100%',
left: '50%',
left: verticalOffset,
transform: 'translateX(-50%)',
},
'&:after': {
left: verticalOffset,
top: '50%',
height: handlebarWidth,
width: handlebarThickness,
},
@ -68,12 +88,14 @@ export const getDragStyles = (theme: GrafanaTheme2) => {
'&:before': {
borderTop: '1px solid transparent',
top: '50%',
top: horizontalOffset,
transform: 'translateY(-50%)',
width: '100%',
},
'&:after': {
left: '50%',
top: horizontalOffset,
height: handlebarThickness,
width: handlebarWidth,
},

View File

@ -0,0 +1,23 @@
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} />

View File

@ -0,0 +1,55 @@
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;

View File

@ -0,0 +1,475 @@
import { css } from '@emotion/css';
import { clamp, throttle } from 'lodash';
import React, { useCallback, useId, useLayoutEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { DragHandlePosition, getDragStyles } from '../DragHandle/DragHandle';
export interface Props {
/**
* The initial size of the primary pane between 0-1, defaults to 0.5
*/
initialSize?: number;
direction?: 'row' | 'column';
dragPosition?: DragHandlePosition;
primaryPaneStyles?: React.CSSProperties;
secondaryPaneStyles?: React.CSSProperties;
/**
* Called when ever the size of the primary pane changes
* @param size (float from 0-1)
*/
onSizeChange?: (size: 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,
onSizeChange,
dragPosition = 'middle',
children,
} = props;
const { containerRef, firstPaneRef, minDimProp, splitterProps, secondPaneRef } = useSplitter(
direction,
onSizeChange,
children
);
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;
const VERTICAL_KEYS = new Set(['ArrowUp', 'ArrowDown']);
const HORIZONTAL_KEYS = new Set(['ArrowLeft', 'ArrowRight']);
const propsForDirection = {
row: {
dim: 'width',
axis: 'clientX',
min: 'minWidth',
max: 'maxWidth',
},
column: {
dim: 'height',
axis: 'clientY',
min: 'minHeight',
max: 'maxHeight',
},
} as const;
function useSplitter(direction: 'row' | 'column', onSizeChange: Props['onSizeChange'], children: Props['children']) {
const handleSize = 16;
const splitterRef = useRef<HTMLDivElement | null>(null);
const firstPaneRef = useRef<HTMLDivElement | null>(null);
const secondPaneRef = useRef<HTMLDivElement | null>(null);
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);
const measurementProp = propsForDirection[direction].dim;
const clientAxis = propsForDirection[direction].axis;
const minDimProp = propsForDirection[direction].min;
const maxDimProp = propsForDirection[direction].max;
// Using a resize observer here, as with content or screen based width/height the ratio between panes might
// change after a window resize, so ariaValueNow needs to be updated accordingly
useResizeObserver(
containerRef.current!,
(entries) => {
for (const entry of entries) {
if (!entry.target.isSameNode(containerRef.current)) {
return;
}
if (!firstPaneRef.current) {
return;
}
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
)}`;
}
},
500,
[maxDimProp, minDimProp, direction, measurementProp]
);
const dragStart = useRef<number | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!firstPaneRef.current) {
return;
}
// measure left-side width
primarySizeRef.current = firstPaneRef.current!.getBoundingClientRect()[measurementProp];
containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp];
// set position at start of drag
dragStart.current = e[clientAxis];
splitterRef.current!.setPointerCapture(e.pointerId);
firstPaneMeasurements.current = measureElement(firstPaneRef.current);
savedPos.current = undefined;
},
[measurementProp, clientAxis]
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (dragStart.current !== null && primarySizeRef.current !== '1fr') {
const diff = e[clientAxis] - dragStart.current;
const dims = firstPaneMeasurements.current!;
const newSize = clamp(primarySizeRef.current + diff, dims[minDimProp], dims[maxDimProp]);
const newFlex = newSize / (containerSize.current! - handleSize);
firstPaneRef.current!.style.flexGrow = `${newFlex}`;
secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`;
const ariaValueNow = clamp(
((newSize - dims[minDimProp]) / (dims[maxDimProp] - dims[minDimProp])) * 100,
0,
100
);
splitterRef.current!.ariaValueNow = `${ariaValueNow}`;
}
},
[handleSize, clientAxis, minDimProp, maxDimProp]
);
const onPointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
splitterRef.current!.releasePointerCapture(e.pointerId);
dragStart.current = null;
onSizeChange?.(parseFloat(firstPaneRef.current!.style.flexGrow));
},
[onSizeChange]
);
const pressedKeys = useRef(new Set<string>());
const keysLastHandledAt = useRef<number | null>(null);
const handlePressedKeys = useCallback(
(time: number) => {
const nothingPressed = pressedKeys.current.size === 0;
if (nothingPressed) {
keysLastHandledAt.current = null;
return;
} else if (primarySizeRef.current === '1fr') {
return;
}
const dt = time - (keysLastHandledAt.current ?? time);
const dx = dt * PIXELS_PER_MS;
let sizeChange = 0;
if (direction === 'row') {
if (pressedKeys.current.has('ArrowLeft')) {
sizeChange -= dx;
}
if (pressedKeys.current.has('ArrowRight')) {
sizeChange += dx;
}
} else {
if (pressedKeys.current.has('ArrowUp')) {
sizeChange -= dx;
}
if (pressedKeys.current.has('ArrowDown')) {
sizeChange += dx;
}
}
const firstPaneDims = firstPaneMeasurements.current!;
const curSize = firstPaneRef.current!.getBoundingClientRect()[measurementProp];
const newSize = clamp(curSize + sizeChange, firstPaneDims[minDimProp], firstPaneDims[maxDimProp]);
const newFlex = newSize / (containerSize.current! - handleSize);
firstPaneRef.current!.style.flexGrow = `${newFlex}`;
secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`;
const ariaValueNow =
((newSize - firstPaneDims[minDimProp]) / (firstPaneDims[maxDimProp] - firstPaneDims[minDimProp])) * 100;
splitterRef.current!.ariaValueNow = `${clamp(ariaValueNow, 0, 100)}`;
keysLastHandledAt.current = time;
window.requestAnimationFrame(handlePressedKeys);
},
[direction, handleSize, minDimProp, maxDimProp, measurementProp]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!firstPaneRef.current || !secondPaneRef.current || !splitterRef.current || !containerRef.current) {
return;
}
if (e.key === 'Enter') {
if (savedPos.current === undefined) {
savedPos.current = firstPaneRef.current!.style.flexGrow;
firstPaneRef.current!.style.flexGrow = '0';
secondPaneRef.current!.style.flexGrow = '1';
} else {
firstPaneRef.current!.style.flexGrow = savedPos.current;
secondPaneRef.current!.style.flexGrow = `${1 - parseFloat(savedPos.current)}`;
savedPos.current = undefined;
}
return;
} else if (e.key === 'Home') {
firstPaneMeasurements.current = measureElement(firstPaneRef.current);
containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp];
const newFlex = firstPaneMeasurements.current[minDimProp] / (containerSize.current - handleSize);
firstPaneRef.current.style.flexGrow = `${newFlex}`;
secondPaneRef.current.style.flexGrow = `${1 - newFlex}`;
splitterRef.current.ariaValueNow = '0';
return;
} else if (e.key === 'End') {
firstPaneMeasurements.current = measureElement(firstPaneRef.current);
containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp];
const newFlex = firstPaneMeasurements.current[maxDimProp] / (containerSize.current - handleSize);
firstPaneRef.current!.style.flexGrow = `${newFlex}`;
secondPaneRef.current!.style.flexGrow = `${1 - newFlex}`;
splitterRef.current!.ariaValueNow = '100';
return;
}
if (
!(
(direction === 'column' && VERTICAL_KEYS.has(e.key)) ||
(direction === 'row' && HORIZONTAL_KEYS.has(e.key))
) ||
pressedKeys.current.has(e.key)
) {
return;
}
savedPos.current = undefined;
e.preventDefault();
e.stopPropagation();
primarySizeRef.current = firstPaneRef.current.getBoundingClientRect()[measurementProp];
containerSize.current = containerRef.current!.getBoundingClientRect()[measurementProp];
firstPaneMeasurements.current = measureElement(firstPaneRef.current);
const newKey = !pressedKeys.current.has(e.key);
if (newKey) {
const initiateAnimationLoop = pressedKeys.current.size === 0;
pressedKeys.current.add(e.key);
if (initiateAnimationLoop) {
window.requestAnimationFrame(handlePressedKeys);
}
}
},
[direction, handlePressedKeys, handleSize, maxDimProp, measurementProp, minDimProp]
);
const onKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (
(direction === 'row' && !HORIZONTAL_KEYS.has(e.key)) ||
(direction === 'column' && !VERTICAL_KEYS.has(e.key))
) {
return;
}
pressedKeys.current.delete(e.key);
onSizeChange?.(parseFloat(firstPaneRef.current!.style.flexGrow));
},
[direction, onSizeChange]
);
const onDoubleClick = useCallback(() => {
if (!firstPaneRef.current || !secondPaneRef.current) {
return;
}
firstPaneRef.current.style.flexGrow = '0.5';
secondPaneRef.current.style.flexGrow = '0.5';
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}`;
}, [maxDimProp, measurementProp, minDimProp]);
const onBlur = useCallback(() => {
// If focus is lost while keys are held, stop changing panel sizes
if (pressedKeys.current.size > 0) {
pressedKeys.current.clear();
dragStart.current = null;
onSizeChange?.(parseFloat(firstPaneRef.current!.style.flexGrow));
}
}, [onSizeChange]);
return {
containerRef,
firstPaneRef,
minDimProp,
splitterProps: {
onPointerUp,
onPointerDown,
onPointerMove,
onKeyDown,
onKeyUp,
onDoubleClick,
onBlur,
ref: splitterRef,
style: { [measurementProp]: `${handleSize}px` },
},
secondPaneRef,
};
}
interface MeasureResult {
minWidth: number;
maxWidth: number;
minHeight: number;
maxHeight: number;
}
function measureElement<T extends HTMLElement>(ref: T): MeasureResult {
const savedBodyOverflow = document.body.style.overflow;
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';
const { width: maxWidth, height: maxHeight } = ref.getBoundingClientRect();
document.body.style.overflow = savedBodyOverflow;
ref.style.width = savedWidth;
ref.style.height = savedHeight;
ref.style.flexGrow = savedFlex;
return { minWidth, maxWidth, minHeight, maxHeight } as MeasureResult;
}
function useResizeObserver(
target: Element,
cb: (entries: ResizeObserverEntry[]) => void,
throttleWait = 0,
deps?: React.DependencyList
) {
const throttledCallback = throttle(cb, throttleWait);
useLayoutEffect(() => {
if (!target) {
return;
}
const resizeObserver = new ResizeObserver(throttledCallback);
resizeObserver.observe(target, { box: 'device-pixel-content-box' });
return () => resizeObserver.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

View File

@ -265,6 +265,7 @@ export { Avatar } from './UsersIndicator/Avatar';
export { InlineFormLabel } from './FormLabel/FormLabel';
export { Divider } from './Divider/Divider';
export { getDragStyles } from './DragHandle/DragHandle';
export { Splitter } from './Splitter/Splitter';
export { LayoutItemContext, type LayoutItemContextProps } from './Layout/LayoutItemContext';