diff --git a/.betterer.results b/.betterer.results
index c4690e9daf2..8f1d4f3aa93 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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"],
diff --git a/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx b/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx
index f0a5c8c5ecf..1c48a33992a 100644
--- a/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx
+++ b/packages/grafana-ui/src/components/DragHandle/DragHandle.tsx
@@ -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,
},
diff --git a/packages/grafana-ui/src/components/Splitter/Splitter.mdx b/packages/grafana-ui/src/components/Splitter/Splitter.mdx
new file mode 100644
index 00000000000..af7d3fc015b
--- /dev/null
+++ b/packages/grafana-ui/src/components/Splitter/Splitter.mdx
@@ -0,0 +1,23 @@
+import { Meta, Preview, ArgTypes } from '@storybook/blocks';
+import { Box, Splitter, Text } from '@grafana/ui';
+
+
+
+# Splitter
+
+The splitter creates two resizable panes, either horizontally or vertically.
+
+
+
+
+
+ Primary
+
+
+ Secondary
+
+
+
+
+
+
diff --git a/packages/grafana-ui/src/components/Splitter/Splitter.story.tsx b/packages/grafana-ui/src/components/Splitter/Splitter.story.tsx
new file mode 100644
index 00000000000..7248f78f3d2
--- /dev/null
+++ b/packages/grafana-ui/src/components/Splitter/Splitter.story.tsx
@@ -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 (
+
+
+
+ Primary
+ Secondary
+
+
+
+ );
+};
+
+Basic.args = {
+ direction: 'row',
+ dragPosition: 'middle',
+};
+
+export default meta;
diff --git a/packages/grafana-ui/src/components/Splitter/Splitter.tsx b/packages/grafana-ui/src/components/Splitter/Splitter.tsx
new file mode 100644
index 00000000000..aa4958a443b
--- /dev/null
+++ b/packages/grafana-ui/src/components/Splitter/Splitter.tsx
@@ -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 (
+
+
+ {kids[0]}
+
+
+ {kids[1] && (
+ <>
+
+
+
+ {kids[1]}
+
+ >
+ )}
+
+ );
+}
+
+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(null);
+ const firstPaneRef = useRef(null);
+ const secondPaneRef = useRef(null);
+ const containerRef = useRef(null);
+ const containerSize = useRef(null);
+ const primarySizeRef = useRef<'1fr' | number>('1fr');
+
+ const firstPaneMeasurements = useRef(undefined);
+ const savedPos = useRef(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(null);
+ const onPointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ 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) => {
+ 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) => {
+ e.preventDefault();
+ e.stopPropagation();
+ splitterRef.current!.releasePointerCapture(e.pointerId);
+ dragStart.current = null;
+ onSizeChange?.(parseFloat(firstPaneRef.current!.style.flexGrow));
+ },
+ [onSizeChange]
+ );
+
+ const pressedKeys = useRef(new Set());
+ const keysLastHandledAt = useRef(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) => {
+ 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) => {
+ 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(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);
+}
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 706175a2ee6..435d63f2442 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -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';