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';