UI/Plot: Implement keyboard controls for plot cursor (#42244)

pull/43996/head
kay delaney 4 years ago committed by GitHub
parent f74b21c119
commit 01dd623daa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 30
      packages/grafana-ui/src/components/VizLayout/VizLayout.tsx
  2. 160
      packages/grafana-ui/src/components/uPlot/plugins/KeyboardPlugin.tsx
  3. 27
      packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx
  4. 1
      packages/grafana-ui/src/components/uPlot/plugins/index.ts
  5. 3
      public/app/plugins/panel/timeseries/TimeSeriesPanel.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<VizLayoutProps> {
* @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<HTMLDivElement>();
if (!legend) {
return <div style={containerStyle}>{children(width, height)}</div>;
return (
<div tabIndex={0} style={containerStyle} className={styles.viz}>
{children(width, height)}
</div>
);
}
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 (
<div style={containerStyle}>
<div style={vizStyle}>{size && children(size.width, size.height)}</div>
<div tabIndex={0} className={styles.viz}>
{size && children(size.width, size.height)}
</div>
<div style={legendStyle} ref={legendRef}>
<CustomScrollbar hideHorizontalTrack>{legend}</CustomScrollbar>
</div>
@ -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;

@ -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<string>();
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<KeyboardPluginProps> = ({ config }) => {
useLayoutEffect(() => config.addHook('init', initHook), [config]);
return null;
};

@ -66,21 +66,21 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
useLayoutEffect(() => {
let bbox: DOMRect | undefined = undefined;
const plotMouseLeave = () => {
const plotEnter = () => {
if (!isMounted()) {
return;
}
setCoords(null);
setIsActive(false);
plotInstance.current?.root.classList.remove('plot-active');
setIsActive(true);
plotInstance.current?.root.classList.add('plot-active');
};
const plotMouseEnter = () => {
const plotLeave = () => {
if (!isMounted()) {
return;
}
setIsActive(true);
plotInstance.current?.root.classList.add('plot-active');
setCoords(null);
setIsActive(false);
plotInstance.current?.root.classList.remove('plot-active');
};
// cache uPlot plotting area bounding box
@ -89,8 +89,11 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
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<TooltipPluginProps> = ({
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]);

@ -1,2 +1,3 @@
export { ZoomPlugin } from './ZoomPlugin';
export { TooltipPlugin } from './TooltipPlugin';
export { KeyboardPlugin } from './KeyboardPlugin';

@ -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<TimeSeriesPanelProps> = ({
{(config, alignedDataFrame) => {
return (
<>
<KeyboardPlugin config={config} />
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
{options.tooltip.mode === TooltipDisplayMode.None || (
<TooltipPlugin

Loading…
Cancel
Save