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:
co-authored by
polinaboneva
Ivan Ortega
Alexandra Vargas
parent
ba731f7865
commit
40ec4ef5b8
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { FocusScope } from '@react-aria/focus';
|
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 { usePopperTooltip } from 'react-popper-tooltip';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
|
|
||||||
@@ -12,12 +12,19 @@ export interface Props {
|
|||||||
overlay: React.ReactElement | (() => React.ReactElement);
|
overlay: React.ReactElement | (() => React.ReactElement);
|
||||||
placement?: TooltipPlacement;
|
placement?: TooltipPlacement;
|
||||||
children: React.ReactElement | ((isOpen: boolean) => React.ReactElement);
|
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 [show, setShow] = useState(false);
|
||||||
const transitionRef = useRef(null);
|
const transitionRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onVisibleChange?.(show);
|
||||||
|
}, [onVisibleChange, show]);
|
||||||
|
|
||||||
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({
|
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({
|
||||||
visible: show,
|
visible: show,
|
||||||
placement: placement,
|
placement: placement,
|
||||||
@@ -25,7 +32,7 @@ export const Dropdown = React.memo(({ children, overlay, placement }: Props) =>
|
|||||||
interactive: true,
|
interactive: true,
|
||||||
delayHide: 0,
|
delayHide: 0,
|
||||||
delayShow: 0,
|
delayShow: 0,
|
||||||
offset: [0, 8],
|
offset: offset ?? [0, 8],
|
||||||
trigger: ['click'],
|
trigger: ['click'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -60,22 +60,12 @@ it('renders panel with a header if prop leftItems', () => {
|
|||||||
expect(screen.getByTestId('header-container')).toBeInTheDocument();
|
expect(screen.getByTestId('header-container')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// todo implement when hoverHeader is implemented
|
it('renders panel with hover header if no title, no leftItems, hoverHeader is undefined but menu is present', () => {
|
||||||
it.skip('renders panel without header if no title, no leftItems, and hoverHeader is undefined', () => {
|
setup({ title: '', leftItems: undefined, hoverHeader: undefined, menu: <div>Menu</div> });
|
||||||
setup();
|
expect(screen.getByTestId('hover-header-container')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByTestId('header-container')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// todo implement when hoverHeader is implemented
|
it('renders panel with a hovering header if prop hoverHeader is true', () => {
|
||||||
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', () => {
|
|
||||||
setup({ title: 'Test Panel Header', hoverHeader: true });
|
setup({ title: 'Test Panel Header', hoverHeader: true });
|
||||||
|
|
||||||
expect(screen.queryByTestId('header-container')).not.toBeInTheDocument();
|
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();
|
expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders panel with a header if prop menu', () => {
|
it('renders panel with a hover header if prop menu is present and hoverHeader is false', () => {
|
||||||
setup({ menu: <div> Menu </div> });
|
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', () => {
|
it('renders panel with a show-on-hover menu icon if prop menu', () => {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
|||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../themes';
|
import { useStyles2, useTheme2 } from '../../themes';
|
||||||
import { Dropdown } from '../Dropdown/Dropdown';
|
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { LoadingBar } from '../LoadingBar/LoadingBar';
|
import { LoadingBar } from '../LoadingBar/LoadingBar';
|
||||||
import { ToolbarButton } from '../ToolbarButton';
|
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
|
import { HoverWidget } from './HoverWidget';
|
||||||
import { PanelDescription } from './PanelDescription';
|
import { PanelDescription } from './PanelDescription';
|
||||||
|
import { PanelMenu } from './PanelMenu';
|
||||||
import { PanelStatus } from './PanelStatus';
|
import { PanelStatus } from './PanelStatus';
|
||||||
import { TitleItem } from './TitleItem';
|
import { TitleItem } from './TitleItem';
|
||||||
|
|
||||||
@@ -23,9 +23,10 @@ export interface PanelChromeProps {
|
|||||||
height: number;
|
height: number;
|
||||||
children: (innerWidth: number, innerHeight: number) => ReactNode;
|
children: (innerWidth: number, innerHeight: number) => ReactNode;
|
||||||
padding?: PanelPadding;
|
padding?: PanelPadding;
|
||||||
|
hoverHeaderOffset?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | (() => string);
|
description?: string | (() => string);
|
||||||
titleItems?: ReactNode[];
|
titleItems?: ReactNode;
|
||||||
menu?: ReactElement | (() => ReactElement);
|
menu?: ReactElement | (() => ReactElement);
|
||||||
dragClass?: string;
|
dragClass?: string;
|
||||||
dragClassCancel?: string;
|
dragClassCancel?: string;
|
||||||
@@ -70,11 +71,12 @@ export function PanelChrome({
|
|||||||
title = '',
|
title = '',
|
||||||
description = '',
|
description = '',
|
||||||
displayMode = 'default',
|
displayMode = 'default',
|
||||||
titleItems = [],
|
titleItems,
|
||||||
menu,
|
menu,
|
||||||
dragClass,
|
dragClass,
|
||||||
dragClassCancel,
|
dragClassCancel,
|
||||||
hoverHeader = false,
|
hoverHeader = false,
|
||||||
|
hoverHeaderOffset,
|
||||||
loadingState,
|
loadingState,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
statusMessageOnClick,
|
statusMessageOnClick,
|
||||||
@@ -91,7 +93,7 @@ export function PanelChrome({
|
|||||||
const hasHeader =
|
const hasHeader =
|
||||||
hoverHeader === false &&
|
hoverHeader === false &&
|
||||||
(title.length > 0 ||
|
(title.length > 0 ||
|
||||||
titleItems.length > 0 ||
|
titleItems !== undefined ||
|
||||||
description !== '' ||
|
description !== '' ||
|
||||||
loadingState === LoadingState.Streaming ||
|
loadingState === LoadingState.Streaming ||
|
||||||
(leftItems?.length ?? 0) > 0);
|
(leftItems?.length ?? 0) > 0);
|
||||||
@@ -105,71 +107,83 @@ export function PanelChrome({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const containerStyles: CSSProperties = { width, height };
|
const containerStyles: CSSProperties = { width, height };
|
||||||
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
|
|
||||||
|
|
||||||
if (displayMode === 'transparent') {
|
if (displayMode === 'transparent') {
|
||||||
containerStyles.backgroundColor = 'transparent';
|
containerStyles.backgroundColor = 'transparent';
|
||||||
containerStyles.border = 'none';
|
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 (
|
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}>
|
<div className={styles.loadingBarContainer}>
|
||||||
{loadingState === LoadingState.Loading ? (
|
{loadingState === LoadingState.Loading ? (
|
||||||
<LoadingBar width={'28%'} height={'2px'} ariaLabel="Panel loading bar" />
|
<LoadingBar width={'28%'} height={'2px'} ariaLabel="Panel loading bar" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container">
|
{(hoverHeader || !hasHeader) && menu && (
|
||||||
{title && (
|
<HoverWidget menu={menu} title={title} offset={hoverHeaderOffset} dragClass={dragClass}>
|
||||||
<h6 title={title} className={styles.title}>
|
{headerContent}
|
||||||
{title}
|
</HoverWidget>
|
||||||
</h6>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<PanelDescription description={description} className={dragClassCancel} />
|
{hasHeader && (
|
||||||
|
<div className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container">
|
||||||
|
{headerContent}
|
||||||
|
|
||||||
{titleItems.length > 0 && (
|
<div className={styles.rightAligned}>
|
||||||
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
|
{menu && (
|
||||||
{titleItems.map((item) => item)}
|
<PanelMenu
|
||||||
</div>
|
menu={menu}
|
||||||
)}
|
title={title}
|
||||||
|
menuButtonClass={cx(styles.menuItem, dragClassCancel, 'show-on-hover')}
|
||||||
{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')}
|
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{leftItems && <div className={styles.leftItems}>{itemsRenderer(leftItems, (item) => item)}</div>}
|
{leftItems && <div className={styles.leftItems}>{itemsRenderer(leftItems, (item) => item)}</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<PanelStatus
|
<PanelStatus
|
||||||
className={cx(styles.errorContainer, dragClassCancel)}
|
className={cx(styles.errorContainer, dragClassCancel)}
|
||||||
message={statusMessage}
|
message={statusMessage}
|
||||||
onClick={statusMessageOnClick}
|
onClick={statusMessageOnClick}
|
||||||
ariaLabel="Panel status"
|
ariaLabel="Panel status"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.content} style={contentStyle}>
|
<div className={styles.content} style={contentStyle}>
|
||||||
{children(innerWidth, innerHeight)}
|
{children(innerWidth, innerHeight)}
|
||||||
@@ -228,10 +242,15 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flex: '1 1 0',
|
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
|
// only show menu icon on hover or focused panel
|
||||||
'.menu-icon': {
|
'.show-on-hover': {
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -239,6 +258,14 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
outline: `1px solid ${theme.colors.action.focus}`,
|
outline: `1px solid ${theme.colors.action.focus}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
regularHeader: css({
|
||||||
|
'&:focus-within': {
|
||||||
|
'.show-on-hover': {
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
loadingBarContainer: css({
|
loadingBarContainer: css({
|
||||||
label: 'panel-loading-bar-container',
|
label: 'panel-loading-bar-container',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -273,6 +300,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
fontSize: theme.typography.h6.fontSize,
|
fontSize: theme.typography.h6.fontSize,
|
||||||
fontWeight: theme.typography.h6.fontWeight,
|
fontWeight: theme.typography.h6.fontWeight,
|
||||||
}),
|
}),
|
||||||
@@ -294,6 +322,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: theme.zIndex.tooltip,
|
||||||
}),
|
}),
|
||||||
leftItems: css({
|
leftItems: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -307,6 +339,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
}),
|
}),
|
||||||
titleItems: css({
|
titleItems: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
import { useStyles2 } from '../../themes';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PanelDescription({ description, className }: Props) {
|
export function PanelDescription({ description, className }: Props) {
|
||||||
const styles = getStyles();
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const getDescriptionContent = (): JSX.Element => {
|
const getDescriptionContent = (): JSX.Element => {
|
||||||
// description
|
// description
|
||||||
@@ -34,7 +37,7 @@ export function PanelDescription({ description, className }: Props) {
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = () => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
description: css({
|
description: css({
|
||||||
code: {
|
code: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
import { PanelMenuItem } from '@grafana/data';
|
import { PanelMenuItem } from '@grafana/data';
|
||||||
@@ -7,13 +8,15 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
items: PanelMenuItem[];
|
items: PanelMenuItem[];
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
itemsClassName?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PanelHeaderMenu extends PureComponent<Props> {
|
export class PanelHeaderMenu extends PureComponent<Props> {
|
||||||
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
|
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
|
||||||
return (
|
return (
|
||||||
<ul
|
<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}
|
style={this.props.style}
|
||||||
role={isSubMenu ? '' : 'menu'}
|
role={isSubMenu ? '' : 'menu'}
|
||||||
>
|
>
|
||||||
@@ -36,6 +39,10 @@ export class PanelHeaderMenu extends PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,28 @@ interface Props {
|
|||||||
loadingState?: LoadingState;
|
loadingState?: LoadingState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
style?: React.CSSProperties;
|
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 (
|
return (
|
||||||
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
|
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
|
||||||
{({ items }) => {
|
{({ items }) => (
|
||||||
return <PanelHeaderMenu style={style} items={items} />;
|
<PanelHeaderMenu
|
||||||
}}
|
className={menuWrapperClassName}
|
||||||
|
itemsClassName={menuItemsClassName}
|
||||||
|
style={style}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PanelHeaderMenuProvider>
|
</PanelHeaderMenuProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) {
|
|||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
menuTrigger: css({
|
menuTrigger: css({
|
||||||
|
height: '100%',
|
||||||
|
background: 'inherit',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: `${theme.shape.borderRadius()}`,
|
borderRadius: `${theme.shape.borderRadius()}`,
|
||||||
cursor: 'context-menu',
|
cursor: 'context-menu',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
@@ -636,9 +637,12 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
const title = panel.getDisplayTitle();
|
const title = panel.getDisplayTitle();
|
||||||
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
|
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
|
||||||
|
|
||||||
const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : '';
|
const showTitleItems =
|
||||||
|
(panel.links && panel.links.length > 0 && this.onShowPanelLinks) ||
|
||||||
const titleItems = [
|
(data.series.length > 0 && data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) ||
|
||||||
|
(data.request && data.request.timeInfo) ||
|
||||||
|
alertState;
|
||||||
|
const titleItems = showTitleItems && (
|
||||||
<PanelHeaderTitleItems
|
<PanelHeaderTitleItems
|
||||||
key="title-items"
|
key="title-items"
|
||||||
alertState={alertState}
|
alertState={alertState}
|
||||||
@@ -646,25 +650,60 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
panelId={panel.id}
|
panelId={panel.id}
|
||||||
panelLinks={panel.links}
|
panelLinks={panel.links}
|
||||||
onShowPanelLinks={this.onShowPanelLinks}
|
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;
|
let menu;
|
||||||
if (!dashboard.meta.publicDashboardAccessToken) {
|
if (!dashboard.meta.publicDashboardAccessToken) {
|
||||||
menu = (
|
menu = (
|
||||||
<div data-testid="panel-dropdown">
|
<div data-testid="panel-dropdown">
|
||||||
<PanelHeaderMenuWrapper
|
<PanelHeaderMenuWrapper
|
||||||
style={{ top: 0 }}
|
style={overrideStyles.pos}
|
||||||
panel={panel}
|
panel={panel}
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
loadingState={data.state}
|
loadingState={data.state}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
|
menuItemsClassName={overrideStyles.menuItemsClassName}
|
||||||
|
menuWrapperClassName={overrideStyles.menuWrapperClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dragClass = !(isViewing || isEditing) ? 'grid-drag-handle' : '';
|
||||||
if (config.featureToggles.newPanelChromeUI) {
|
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 (
|
return (
|
||||||
<PanelChrome
|
<PanelChrome
|
||||||
width={width}
|
width={width}
|
||||||
@@ -679,6 +718,8 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
|||||||
dragClass={dragClass}
|
dragClass={dragClass}
|
||||||
dragClassCancel="grid-drag-cancel"
|
dragClassCancel="grid-drag-cancel"
|
||||||
padding={padding}
|
padding={padding}
|
||||||
|
hoverHeaderOffset={hoverHeaderOffset}
|
||||||
|
hoverHeader={title ? false : true}
|
||||||
displayMode={transparent ? 'transparent' : 'default'}
|
displayMode={transparent ? 'transparent' : 'default'}
|
||||||
>
|
>
|
||||||
{(innerWidth, innerHeight) => (
|
{(innerWidth, innerHeight) => (
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ export class PanelModel implements DataConfigSource, IPanelModel {
|
|||||||
if (manuallyUpdated) {
|
if (manuallyUpdated) {
|
||||||
this.configRev++;
|
this.configRev++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maybe a bit heavy. Could add a "GridPosChanged" event instead?
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
runAllPanelQueries({
|
runAllPanelQueries({
|
||||||
|
|||||||
Reference in New Issue
Block a user