diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx index d171e0889ad..83444158386 100644 --- a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx @@ -1,7 +1,11 @@ import React, { FC, CSSProperties, ComponentType } from 'react'; import { useMeasure } from 'react-use'; -import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { css } from '@emotion/css'; import { LegendPlacement } from '@grafana/schema'; +import { GrafanaTheme2 } from '@grafana/data'; +import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { getFocusStyles } from '../../themes/mixins'; +import { useStyles2 } from '../../themes/ThemeContext'; /** * @beta @@ -24,6 +28,7 @@ export interface VizLayoutComponentType extends FC { * @beta */ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, children }) => { + const styles = useStyles2(getVizStyles); const containerStyle: CSSProperties = { display: 'flex', width: `${width}px`, @@ -32,17 +37,17 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child const [legendRef, legendMeasure] = useMeasure(); if (!legend) { - return
{children(width, height)}
; + return ( +
+ {children(width, height)} +
+ ); } const { placement, maxHeight = '35%', maxWidth = '60%' } = legend.props; let size: VizSize | null = null; - const vizStyle: CSSProperties = { - flexGrow: 2, - }; - const legendStyle: CSSProperties = {}; switch (placement) { @@ -76,7 +81,9 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child return (
-
{size && children(size.width, size.height)}
+
+ {size && children(size.width, size.height)} +
{legend}
@@ -84,6 +91,15 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child ); }; +export const getVizStyles = (theme: GrafanaTheme2) => { + return { + viz: css({ + flexGrow: 2, + borderRadius: theme.shape.borderRadius(1), + '&:focus-visible': getFocusStyles(theme), + }), + }; +}; interface VizSize { width: number; height: number; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/KeyboardPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/KeyboardPlugin.tsx new file mode 100644 index 00000000000..975f3b8dab2 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/KeyboardPlugin.tsx @@ -0,0 +1,160 @@ +import React, { useLayoutEffect } from 'react'; +import { clamp } from 'lodash'; +import uPlot from 'uplot'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +interface KeyboardPluginProps { + config: UPlotConfigBuilder; // onkeypress, onkeyup, onkeydown (triggered by vizlayout handlers) +} + +const PIXELS_PER_MS = 0.1 as const; +const SHIFT_MULTIPLIER = 2 as const; +const KNOWN_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Shift', ' ']); + +const initHook = (u: uPlot) => { + let vizLayoutViz: HTMLElement | null = u.root.closest('[tabindex]'); + let pressedKeys = new Set(); + let dragStartX: number | null = null; + let keysLastHandledAt: number | null = null; + + if (!vizLayoutViz) { + return; + } + + const moveCursor = (dx: number, dy: number) => { + const { cursor } = u; + if (cursor.left === undefined || cursor.top === undefined) { + return; + } + + const { width, height } = u.over.style; + const [maxX, maxY] = [Math.floor(parseFloat(width)), Math.floor(parseFloat(height))]; + + u.setCursor({ + left: clamp(cursor.left + dx, 0, maxX), + top: clamp(cursor.top + dy, 0, maxY), + }); + }; + + const handlePressedKeys = (time: number) => { + const nothingPressed = pressedKeys.size === 0; + if (nothingPressed || !u) { + keysLastHandledAt = null; + return; + } + + const dt = time - (keysLastHandledAt ?? time); + const dx = dt * PIXELS_PER_MS; + let horValue = 0; + let vertValue = 0; + + if (pressedKeys.has('ArrowUp')) { + vertValue -= dx; + } + if (pressedKeys.has('ArrowDown')) { + vertValue += dx; + } + if (pressedKeys.has('ArrowLeft')) { + horValue -= dx; + } + if (pressedKeys.has('ArrowRight')) { + horValue += dx; + } + if (pressedKeys.has('Shift')) { + horValue *= SHIFT_MULTIPLIER; + vertValue *= SHIFT_MULTIPLIER; + } + + moveCursor(horValue, vertValue); + + const { cursor } = u; + if (pressedKeys.has(' ') && cursor) { + const drawHeight = Number(u.over.style.height.slice(0, -2)); + + u.setSelect( + { + left: cursor.left! < dragStartX! ? cursor.left! : dragStartX!, + top: 0, + width: Math.abs(cursor.left! - (dragStartX ?? cursor.left!)), + height: drawHeight, + }, + false + ); + } + + keysLastHandledAt = time; + window.requestAnimationFrame(handlePressedKeys); + }; + + vizLayoutViz.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + // Hide the cursor if the user tabs away + u.setCursor({ left: -5, top: -5 }); + return; + } + + if (!KNOWN_KEYS.has(e.key)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const newKey = !pressedKeys.has(e.key); + if (newKey) { + const initiateAnimationLoop = pressedKeys.size === 0; + pressedKeys.add(e.key); + + dragStartX = e.key === ' ' && dragStartX === null ? u.cursor.left! : dragStartX; + + if (initiateAnimationLoop) { + window.requestAnimationFrame(handlePressedKeys); + } + } + }); + + vizLayoutViz.addEventListener('keyup', (e) => { + if (!KNOWN_KEYS.has(e.key)) { + return; + } + + pressedKeys.delete(e.key); + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + // We do this so setSelect hooks get fired, zooming the plot + u.setSelect(u.select); + dragStartX = null; + } + }); + + vizLayoutViz.addEventListener('focus', (e) => { + // We only want to initialize the cursor if the user is using keyboard controls + if (!vizLayoutViz?.matches(':focus-visible')) { + return; + } + + // Is there a more idiomatic way to do this? + const drawWidth = parseFloat(u.over.style.width); + const drawHeight = parseFloat(u.over.style.height); + u.setCursor({ left: drawWidth / 2, top: drawHeight / 2 }); + }); + + vizLayoutViz.addEventListener('blur', (e) => { + keysLastHandledAt = null; + dragStartX = null; + pressedKeys.clear(); + u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); + }); +}; + +/** + * @alpha + */ +export const KeyboardPlugin: React.FC = ({ config }) => { + useLayoutEffect(() => config.addHook('init', initHook), [config]); + + return null; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 75ebb4e8d8f..f6eaaf0c937 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -66,7 +66,15 @@ export const TooltipPlugin: React.FC = ({ useLayoutEffect(() => { let bbox: DOMRect | undefined = undefined; - const plotMouseLeave = () => { + const plotEnter = () => { + if (!isMounted()) { + return; + } + setIsActive(true); + plotInstance.current?.root.classList.add('plot-active'); + }; + + const plotLeave = () => { if (!isMounted()) { return; } @@ -75,22 +83,17 @@ export const TooltipPlugin: React.FC = ({ plotInstance.current?.root.classList.remove('plot-active'); }; - const plotMouseEnter = () => { - if (!isMounted()) { - return; - } - setIsActive(true); - plotInstance.current?.root.classList.add('plot-active'); - }; - // cache uPlot plotting area bounding box config.addHook('syncRect', (u, rect) => (bbox = rect)); config.addHook('init', (u) => { plotInstance.current = u; - u.over.addEventListener('mouseleave', plotMouseLeave); - u.over.addEventListener('mouseenter', plotMouseEnter); + u.root.parentElement?.addEventListener('focus', plotEnter); + u.over.addEventListener('mouseenter', plotEnter); + + u.root.parentElement?.addEventListener('blur', plotLeave); + u.over.addEventListener('mouseleave', plotLeave); if (sync === DashboardCursorSync.Crosshair) { u.root.classList.add('shared-crosshair'); @@ -157,8 +160,10 @@ export const TooltipPlugin: React.FC = ({ return () => { setCoords(null); if (plotInstance.current) { - plotInstance.current.over.removeEventListener('mouseleave', plotMouseLeave); - plotInstance.current.over.removeEventListener('mouseenter', plotMouseEnter); + plotInstance.current.over.removeEventListener('mouseleave', plotLeave); + plotInstance.current.over.removeEventListener('mouseenter', plotEnter); + plotInstance.current.root.parentElement?.removeEventListener('focus', plotEnter); + plotInstance.current.root.parentElement?.removeEventListener('blur', plotLeave); } }; }, [config, setCoords, setIsActive, setFocusedPointIdx, setFocusedPointIdxs]); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/index.ts b/packages/grafana-ui/src/components/uPlot/plugins/index.ts index 4d8cfbb9f9f..23c742bab8c 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/index.ts +++ b/packages/grafana-ui/src/components/uPlot/plugins/index.ts @@ -1,2 +1,3 @@ export { ZoomPlugin } from './ZoomPlugin'; export { TooltipPlugin } from './TooltipPlugin'; +export { KeyboardPlugin } from './KeyboardPlugin'; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index d347abe441a..64c4b6ea7df 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { Field, PanelProps } from '@grafana/data'; import { TooltipDisplayMode } from '@grafana/schema'; -import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin } from '@grafana/ui'; +import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin, KeyboardPlugin } from '@grafana/ui'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; import { ContextMenuPlugin } from './plugins/ContextMenuPlugin'; @@ -54,6 +54,7 @@ export const TimeSeriesPanel: React.FC = ({ {(config, alignedDataFrame) => { return ( <> + {options.tooltip.mode === TooltipDisplayMode.None || (