PanelChrome: Implement hover header (#61774)

Closes #59078

Co-authored-by: polinaboneva <polina.boneva@grafana.com>
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
This commit is contained in:
kay delaney 2023-02-02 17:53:18 +00:00 committed by GitHub
parent ba731f7865
commit 40ec4ef5b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 336 additions and 88 deletions

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { FocusScope } from '@react-aria/focus';
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { CSSTransition } from 'react-transition-group';
@ -12,12 +12,19 @@ export interface Props {
overlay: React.ReactElement | (() => React.ReactElement);
placement?: TooltipPlacement;
children: React.ReactElement | ((isOpen: boolean) => React.ReactElement);
/** Amount in pixels to nudge the dropdown vertically and horizontally, respectively. */
offset?: [number, number];
onVisibleChange?: (state: boolean) => void;
}
export const Dropdown = React.memo(({ children, overlay, placement }: Props) => {
export const Dropdown = React.memo(({ children, overlay, placement, offset, onVisibleChange }: Props) => {
const [show, setShow] = useState(false);
const transitionRef = useRef(null);
useEffect(() => {
onVisibleChange?.(show);
}, [onVisibleChange, show]);
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({
visible: show,
placement: placement,
@ -25,7 +32,7 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) =>
interactive: true,
delayHide: 0,
delayShow: 0,
offset: [0, 8],
offset: offset ?? [0, 8],
trigger: ['click'],
});

View File

@ -0,0 +1,108 @@
import { css } from '@emotion/css';
import classnames from 'classnames';
import React, { ReactElement, useCallback, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { Icon } from '../Icon/Icon';
import { PanelMenu } from './PanelMenu';
interface Props {
children?: React.ReactNode;
menu: ReactElement | (() => ReactElement);
title?: string;
offset?: number;
dragClass?: string;
}
export function HoverWidget({ menu, title, dragClass, children, offset = -32 }: Props) {
const styles = useStyles2(getStyles);
const draggableRef = useRef<HTMLDivElement>(null);
// Capture the pointer to keep the widget visible while dragging
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.setPointerCapture(e.pointerId);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggableRef.current?.releasePointerCapture(e.pointerId);
}, []);
const [menuOpen, setMenuOpen] = useState(false);
if (children === undefined || React.Children.count(children) === 0) {
return null;
}
return (
<div
className={classnames(styles.container, { 'show-on-hover': !menuOpen })}
style={{ top: `${offset}px` }}
data-testid="hover-header-container"
>
<div
className={classnames(styles.square, styles.draggable, dragClass)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
ref={draggableRef}
>
<Icon name="draggabledots" />
</div>
{children}
<div className={styles.square}>
<PanelMenu
menu={menu}
title={title}
placement="bottom"
menuButtonClass={styles.menuButton}
onVisibleChange={setMenuOpen}
/>
</div>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
hidden: css({
visibility: 'hidden',
opacity: '0',
}),
container: css({
label: 'hover-container-widget',
transition: `all .1s linear`,
display: 'flex',
position: 'absolute',
zIndex: 1,
boxSizing: 'border-box',
alignItems: 'center',
background: theme.colors.background.secondary,
color: theme.colors.text.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: '1px',
height: theme.spacing(4),
boxShadow: theme.shadows.z1,
}),
square: css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: theme.spacing(4),
height: '100%',
}),
draggable: css({
cursor: 'move',
}),
menuButton: css({
color: theme.colors.text.primary,
'&:hover': {
background: 'inherit',
},
}),
title: css({
padding: theme.spacing(0.75),
}),
};
}

View File

@ -60,22 +60,12 @@ it('renders panel with a header if prop leftItems', () => {
expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
// todo implement when hoverHeader is implemented
it.skip('renders panel without header if no title, no leftItems, and hoverHeader is undefined', () => {
setup();
expect(screen.getByTestId('header-container')).toBeInTheDocument();
it('renders panel with hover header if no title, no leftItems, hoverHeader is undefined but menu is present', () => {
setup({ title: '', leftItems: undefined, hoverHeader: undefined, menu: <div>Menu</div> });
expect(screen.getByTestId('hover-header-container')).toBeInTheDocument();
});
// todo implement when hoverHeader is implemented
it.skip('renders panel with a fixed header if prop hoverHeader is false', () => {
setup({ hoverHeader: false });
expect(screen.getByTestId('header-container')).toBeInTheDocument();
});
// todo implement when hoverHeader is implemented
it.skip('renders panel with a hovering header if prop hoverHeader is true', () => {
it('renders panel with a hovering header if prop hoverHeader is true', () => {
setup({ title: 'Test Panel Header', hoverHeader: true });
expect(screen.queryByTestId('header-container')).not.toBeInTheDocument();
@ -97,10 +87,10 @@ it('renders panel with a header with icons in place if prop titleItems', () => {
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
});
it('renders panel with a header if prop menu', () => {
setup({ menu: <div> Menu </div> });
it('renders panel with a hover header if prop menu is present and hoverHeader is false', () => {
setup({ menu: <div> Menu </div>, hoverHeader: false });
expect(screen.getByTestId('header-container')).toBeInTheDocument();
expect(screen.getByTestId('hover-header-container')).toBeInTheDocument();
});
it('renders panel with a show-on-hover menu icon if prop menu', () => {

View File

@ -5,13 +5,13 @@ import { GrafanaTheme2, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2, useTheme2 } from '../../themes';
import { Dropdown } from '../Dropdown/Dropdown';
import { Icon } from '../Icon/Icon';
import { LoadingBar } from '../LoadingBar/LoadingBar';
import { ToolbarButton } from '../ToolbarButton';
import { Tooltip } from '../Tooltip';
import { HoverWidget } from './HoverWidget';
import { PanelDescription } from './PanelDescription';
import { PanelMenu } from './PanelMenu';
import { PanelStatus } from './PanelStatus';
import { TitleItem } from './TitleItem';
@ -23,9 +23,10 @@ export interface PanelChromeProps {
height: number;
children: (innerWidth: number, innerHeight: number) => ReactNode;
padding?: PanelPadding;
hoverHeaderOffset?: number;
title?: string;
description?: string | (() => string);
titleItems?: ReactNode[];
titleItems?: ReactNode;
menu?: ReactElement | (() => ReactElement);
dragClass?: string;
dragClassCancel?: string;
@ -70,11 +71,12 @@ export function PanelChrome({
title = '',
description = '',
displayMode = 'default',
titleItems = [],
titleItems,
menu,
dragClass,
dragClassCancel,
hoverHeader = false,
hoverHeaderOffset,
loadingState,
statusMessage,
statusMessageOnClick,
@ -91,7 +93,7 @@ export function PanelChrome({
const hasHeader =
hoverHeader === false &&
(title.length > 0 ||
titleItems.length > 0 ||
titleItems !== undefined ||
description !== '' ||
loadingState === LoadingState.Streaming ||
(leftItems?.length ?? 0) > 0);
@ -105,71 +107,83 @@ export function PanelChrome({
};
const containerStyles: CSSProperties = { width, height };
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
if (displayMode === 'transparent') {
containerStyles.backgroundColor = 'transparent';
containerStyles.border = 'none';
}
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
const headerContent = (
<>
{title && (
<h6 title={title} className={styles.title}>
{title}
</h6>
)}
<PanelDescription description={description} className={dragClassCancel} />
{titleItems !== undefined && (
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
{titleItems}
</div>
)}
{loadingState === LoadingState.Streaming && (
<Tooltip content="Streaming">
<TitleItem className={dragClassCancel} data-testid="panel-streaming">
<Icon name="circle-mono" size="md" className={styles.streaming} />
</TitleItem>
</Tooltip>
)}
</>
);
return (
<div className={styles.container} style={containerStyles} aria-label={ariaLabel}>
<div
className={cx(styles.container, { [styles.regularHeader]: hasHeader })}
style={containerStyles}
aria-label={ariaLabel}
>
<div className={styles.loadingBarContainer}>
{loadingState === LoadingState.Loading ? (
<LoadingBar width={'28%'} height={'2px'} ariaLabel="Panel loading bar" />
) : null}
</div>
<div className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container">
{title && (
<h6 title={title} className={styles.title}>
{title}
</h6>
)}
{(hoverHeader || !hasHeader) && menu && (
<HoverWidget menu={menu} title={title} offset={hoverHeaderOffset} dragClass={dragClass}>
{headerContent}
</HoverWidget>
)}
<PanelDescription description={description} className={dragClassCancel} />
{hasHeader && (
<div className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container">
{headerContent}
{titleItems.length > 0 && (
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
{titleItems.map((item) => item)}
</div>
)}
{loadingState === LoadingState.Streaming && (
<Tooltip content="Streaming">
<TitleItem className={dragClassCancel} data-testid="panel-streaming">
<Icon name="circle-mono" size="md" className={styles.streaming} />
</TitleItem>
</Tooltip>
)}
<div className={styles.rightAligned}>
{menu && (
<Dropdown overlay={menu} placement="bottom">
<ToolbarButton
aria-label={`Menu for panel with ${title ? `title ${title}` : 'no title'}`}
title="Menu"
icon="ellipsis-v"
iconSize="md"
narrow
data-testid="panel-menu-button"
className={cx(styles.menuItem, dragClassCancel, 'menu-icon')}
<div className={styles.rightAligned}>
{menu && (
<PanelMenu
menu={menu}
title={title}
menuButtonClass={cx(styles.menuItem, dragClassCancel, 'show-on-hover')}
/>
</Dropdown>
)}
)}
{leftItems && <div className={styles.leftItems}>{itemsRenderer(leftItems, (item) => item)}</div>}
{leftItems && <div className={styles.leftItems}>{itemsRenderer(leftItems, (item) => item)}</div>}
</div>
</div>
)}
{statusMessage && (
<PanelStatus
className={cx(styles.errorContainer, dragClassCancel)}
message={statusMessage}
onClick={statusMessageOnClick}
ariaLabel="Panel status"
/>
)}
</div>
{statusMessage && (
<PanelStatus
className={cx(styles.errorContainer, dragClassCancel)}
message={statusMessage}
onClick={statusMessageOnClick}
ariaLabel="Panel status"
/>
)}
<div className={styles.content} style={contentStyle}>
{children(innerWidth, innerHeight)}
@ -228,10 +242,15 @@ const getStyles = (theme: GrafanaTheme2) => {
flexDirection: 'column',
flex: '1 1 0',
'&:focus-within, &:hover': {
'.show-on-hover': {
visibility: 'hidden',
opacity: '0',
},
'&:focus-visible, &:hover': {
// only show menu icon on hover or focused panel
'.menu-icon': {
'.show-on-hover': {
visibility: 'visible',
opacity: '1',
},
},
@ -239,6 +258,14 @@ const getStyles = (theme: GrafanaTheme2) => {
outline: `1px solid ${theme.colors.action.focus}`,
},
}),
regularHeader: css({
'&:focus-within': {
'.show-on-hover': {
visibility: 'visible',
opacity: '1',
},
},
}),
loadingBarContainer: css({
label: 'panel-loading-bar-container',
position: 'absolute',
@ -273,6 +300,7 @@ const getStyles = (theme: GrafanaTheme2) => {
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
maxWidth: theme.spacing(50),
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
}),
@ -294,6 +322,10 @@ const getStyles = (theme: GrafanaTheme2) => {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: theme.zIndex.tooltip,
}),
leftItems: css({
display: 'flex',
@ -307,6 +339,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
titleItems: css({
display: 'flex',
height: '100%',
}),
};
};

View File

@ -1,6 +1,9 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { Icon } from '../Icon/Icon';
import { Tooltip } from '../Tooltip';
@ -12,7 +15,7 @@ interface Props {
}
export function PanelDescription({ description, className }: Props) {
const styles = getStyles();
const styles = useStyles2(getStyles);
const getDescriptionContent = (): JSX.Element => {
// description
@ -34,7 +37,7 @@ export function PanelDescription({ description, className }: Props) {
) : null;
}
const getStyles = () => {
const getStyles = (theme: GrafanaTheme2) => {
return {
description: css({
code: {

View File

@ -0,0 +1,40 @@
import { cx } from '@emotion/css';
import React, { ReactElement } from 'react';
import { Dropdown } from '../Dropdown/Dropdown';
import { ToolbarButton } from '../ToolbarButton';
import { TooltipPlacement } from '../Tooltip';
interface PanelMenuProps {
menu: ReactElement | (() => ReactElement);
menuButtonClass?: string;
dragClassCancel?: string;
title?: string;
placement?: TooltipPlacement;
offset?: [number, number];
onVisibleChange?: (state: boolean) => void;
}
export function PanelMenu({
menu,
title,
placement = 'bottom',
offset,
dragClassCancel,
menuButtonClass,
onVisibleChange,
}: PanelMenuProps) {
return (
<Dropdown overlay={menu} placement={placement} offset={offset} onVisibleChange={onVisibleChange}>
<ToolbarButton
aria-label={`Menu for panel with ${title ? `title ${title}` : 'no title'}`}
title="Menu"
icon="ellipsis-v"
iconSize="md"
narrow
data-testid="panel-menu-button"
className={cx(menuButtonClass, dragClassCancel)}
/>
</Dropdown>
);
}

View File

@ -1,3 +1,4 @@
import classnames from 'classnames';
import React, { PureComponent } from 'react';
import { PanelMenuItem } from '@grafana/data';
@ -7,13 +8,15 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
export interface Props {
items: PanelMenuItem[];
style?: React.CSSProperties;
itemsClassName?: string;
className?: string;
}
export class PanelHeaderMenu extends PureComponent<Props> {
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
return (
<ul
className="dropdown-menu dropdown-menu--menu panel-menu"
className={classnames('dropdown-menu', 'dropdown-menu--menu', 'panel-menu', this.props.itemsClassName)}
style={this.props.style}
role={isSubMenu ? '' : 'menu'}
>
@ -36,6 +39,10 @@ export class PanelHeaderMenu extends PureComponent<Props> {
};
render() {
return <div className="panel-menu-container dropdown open">{this.renderItems(this.props.items)}</div>;
return (
<div className={classnames('panel-menu-container', 'dropdown', 'open', this.props.className)}>
{this.renderItems(this.props.items)}
</div>
);
}
}

View File

@ -13,14 +13,28 @@ interface Props {
loadingState?: LoadingState;
onClose: () => void;
style?: React.CSSProperties;
menuItemsClassName?: string;
menuWrapperClassName?: string;
}
export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) {
export function PanelHeaderMenuWrapper({
style,
panel,
dashboard,
loadingState,
menuItemsClassName,
menuWrapperClassName,
}: Props) {
return (
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
{({ items }) => {
return <PanelHeaderMenu style={style} items={items} />;
}}
{({ items }) => (
<PanelHeaderMenu
className={menuWrapperClassName}
itemsClassName={menuItemsClassName}
style={style}
items={items}
/>
)}
</PanelHeaderMenuProvider>
);
}

View File

@ -47,6 +47,8 @@ export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) {
const getStyles = (theme: GrafanaTheme2) => {
return {
menuTrigger: css({
height: '100%',
background: 'inherit',
border: 'none',
borderRadius: `${theme.shape.borderRadius()}`,
cursor: 'context-menu',

View File

@ -1,3 +1,4 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import React, { PureComponent } from 'react';
import { Subscription } from 'rxjs';
@ -636,9 +637,12 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
const title = panel.getDisplayTitle();
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : '';
const titleItems = [
const showTitleItems =
(panel.links && panel.links.length > 0 && this.onShowPanelLinks) ||
(data.series.length > 0 && data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) ||
(data.request && data.request.timeInfo) ||
alertState;
const titleItems = showTitleItems && (
<PanelHeaderTitleItems
key="title-items"
alertState={alertState}
@ -646,25 +650,60 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
panelId={panel.id}
panelLinks={panel.links}
onShowPanelLinks={this.onShowPanelLinks}
/>,
];
/>
);
const overrideStyles: { menuItemsClassName?: string; menuWrapperClassName?: string; pos?: React.CSSProperties } = {
menuItemsClassName: undefined,
menuWrapperClassName: undefined,
pos: { top: 0, left: '-156px' },
};
if (config.featureToggles.newPanelChromeUI) {
// set override styles
overrideStyles.menuItemsClassName = css`
width: inherit;
top: inherit;
left: inherit;
position: inherit;
float: inherit;
`;
overrideStyles.menuWrapperClassName = css`
position: inherit;
width: inherit;
top: inherit;
left: inherit;
float: inherit;
.dropdown-submenu > .dropdown-menu {
position: absolute;
}
`;
overrideStyles.pos = undefined;
}
// custom styles is neeeded to override legacy panel-menu styles and prevent menu from being cut off
let menu;
if (!dashboard.meta.publicDashboardAccessToken) {
menu = (
<div data-testid="panel-dropdown">
<PanelHeaderMenuWrapper
style={{ top: 0 }}
style={overrideStyles.pos}
panel={panel}
dashboard={dashboard}
loadingState={data.state}
onClose={() => {}}
menuItemsClassName={overrideStyles.menuItemsClassName}
menuWrapperClassName={overrideStyles.menuWrapperClassName}
/>
</div>
);
}
const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : '';
if (config.featureToggles.newPanelChromeUI) {
// Shift the hover menu down if it's on the top row so it doesn't get clipped by topnav
const hoverHeaderOffset = (panel.gridPos?.y ?? 0) === 0 ? -16 : undefined;
return (
<PanelChrome
width={width}
@ -679,6 +718,8 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
dragClass={dragClass}
dragClassCancel="grid-drag-cancel"
padding={padding}
hoverHeaderOffset={hoverHeaderOffset}
hoverHeader={title ? false : true}
displayMode={transparent ? 'transparent' : 'default'}
>
{(innerWidth, innerHeight) => (

View File

@ -339,6 +339,9 @@ export class PanelModel implements DataConfigSource, IPanelModel {
if (manuallyUpdated) {
this.configRev++;
}
// Maybe a bit heavy. Could add a "GridPosChanged" event instead?
this.render();
}
runAllPanelQueries({