mirror of
https://github.com/grafana/grafana.git
synced 2025-02-15 01:53:33 -06:00
Frontend: Allows PanelChrome to be collapsed (#71991)
* Collapsible PanelChrome v1 * Enable either Collapsible or HoverHeader modes * Clean up * Update story * Add test * Revert to 'strict' * Use useToggle * Allow collapsibility when title is not passed * Fix semantics and ellipsis wrapping * Improve accessibility * Add documentation
This commit is contained in:
parent
5707f512be
commit
8dd66090be
@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { Meta } from '@storybook/addon-docs/blocks';
|
import { Meta, Preview } from '@storybook/addon-docs/blocks';
|
||||||
import { PanelChrome } from './PanelChrome';
|
import { PanelChrome } from './PanelChrome';
|
||||||
|
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
@ -504,3 +504,51 @@ Component used for rendering content wrapped in the same style as grafana panels
|
|||||||
}}
|
}}
|
||||||
</PanelChrome>
|
</PanelChrome>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
|
||||||
|
### Collapsible
|
||||||
|
|
||||||
|
The panel can be collapsed/expanded by clicking on the chevron or the title.
|
||||||
|
|
||||||
|
> _Note: `collapsible` and `hoverHeader` props are mutually exclusive and cannot be used in the same panel._
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PanelChrome title="My awesome panel title" width={400} height={200} collapsible={true}>
|
||||||
|
{(innerwidth, innerheight) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: innerwidth,
|
||||||
|
height: innerheight,
|
||||||
|
background: 'rgba(230,0,0,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PanelChrome>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Preview>
|
||||||
|
<PanelChrome title="My awesome panel title" width={400} height={200} collapsible={true}>
|
||||||
|
{(innerwidth, innerheight) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: innerwidth,
|
||||||
|
height: innerheight,
|
||||||
|
background: 'rgba(230,0,0,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PanelChrome>
|
||||||
|
</Preview>
|
||||||
|
@ -215,6 +215,10 @@ export const Examples = () => {
|
|||||||
/>,
|
/>,
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
|
{renderPanel('Collapsible panel', {
|
||||||
|
title: 'Default title',
|
||||||
|
collapsible: true,
|
||||||
|
})}
|
||||||
{renderPanel('Panel with action link', {
|
{renderPanel('Panel with action link', {
|
||||||
title: 'Panel with action link',
|
title: 'Panel with action link',
|
||||||
actions: (
|
actions: (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { screen, render } from '@testing-library/react';
|
import { screen, render, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { LoadingState } from '@grafana/data';
|
import { LoadingState } from '@grafana/data';
|
||||||
@ -124,3 +124,20 @@ it('renders streaming indicator in the panel header if loadingState is streaming
|
|||||||
|
|
||||||
expect(screen.getByTestId('panel-streaming')).toBeInTheDocument();
|
expect(screen.getByTestId('panel-streaming')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collapes the panel when user clicks on the chevron or the title', () => {
|
||||||
|
setup({ collapsible: true, title: 'Default title' });
|
||||||
|
|
||||||
|
expect(screen.getByText("Panel's Content")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const button = screen.getByText('Default title');
|
||||||
|
// collapse button should have same aria-controls as the panel's content
|
||||||
|
expect(button.getAttribute('aria-controls')).toBe(button.parentElement?.parentElement?.nextElementSibling?.id);
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Panel's Content")).not.toBeInTheDocument();
|
||||||
|
// aria-controls should be removed when panel is collapsed
|
||||||
|
expect(button).not.toHaveAttribute('aria-controlls');
|
||||||
|
expect(button.parentElement?.parentElement?.nextElementSibling?.id).toBe(undefined);
|
||||||
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React, { CSSProperties, ReactElement, ReactNode } from 'react';
|
import React, { CSSProperties, ReactElement, ReactNode, useId } from 'react';
|
||||||
import { useMeasure } from 'react-use';
|
import { useMeasure, useToggle } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
@ -21,17 +21,16 @@ import { TitleItem } from './TitleItem';
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export type PanelChromeProps = FixedDimensions | AutoSize;
|
export type PanelChromeProps = (AutoSize | FixedDimensions) & (Collapsible | HoverHeader);
|
||||||
|
|
||||||
interface BaseProps {
|
interface BaseProps {
|
||||||
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;
|
||||||
hoverHeader?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Use only to indicate loading or streaming data in the panel.
|
* Use only to indicate loading or streaming data in the panel.
|
||||||
* Any other values of loadingState are ignored.
|
* Any other values of loadingState are ignored.
|
||||||
@ -70,6 +69,18 @@ interface AutoSize extends BaseProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Collapsible {
|
||||||
|
collapsible: boolean;
|
||||||
|
hoverHeader?: never;
|
||||||
|
hoverHeaderOffset?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoverHeader {
|
||||||
|
collapsible?: never;
|
||||||
|
hoverHeader?: boolean;
|
||||||
|
hoverHeaderOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@ -99,9 +110,13 @@ export function PanelChrome({
|
|||||||
actions,
|
actions,
|
||||||
onCancelQuery,
|
onCancelQuery,
|
||||||
onOpenMenu,
|
onOpenMenu,
|
||||||
|
collapsible = false,
|
||||||
}: PanelChromeProps) {
|
}: PanelChromeProps) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const panelContentId = useId();
|
||||||
|
|
||||||
|
const [isOpen, toggleOpen] = useToggle(true);
|
||||||
|
|
||||||
const hasHeader = !hoverHeader;
|
const hasHeader = !hoverHeader;
|
||||||
|
|
||||||
@ -109,14 +124,21 @@ export function PanelChrome({
|
|||||||
const showOnHoverClass = 'show-on-hover';
|
const showOnHoverClass = 'show-on-hover';
|
||||||
|
|
||||||
const headerHeight = getHeaderHeight(theme, hasHeader);
|
const headerHeight = getHeaderHeight(theme, hasHeader);
|
||||||
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, headerHeight, height, width);
|
const { contentStyle, innerWidth, innerHeight } = getContentStyle(
|
||||||
|
padding,
|
||||||
|
theme,
|
||||||
|
headerHeight,
|
||||||
|
isOpen,
|
||||||
|
height,
|
||||||
|
width
|
||||||
|
);
|
||||||
|
|
||||||
const headerStyles: CSSProperties = {
|
const headerStyles: CSSProperties = {
|
||||||
height: headerHeight,
|
height: headerHeight,
|
||||||
cursor: dragClass ? 'move' : 'auto',
|
cursor: dragClass ? 'move' : 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerStyles: CSSProperties = { width, height };
|
const containerStyles: CSSProperties = { width, height: isOpen ? height : headerHeight };
|
||||||
if (displayMode === 'transparent') {
|
if (displayMode === 'transparent') {
|
||||||
containerStyles.backgroundColor = 'transparent';
|
containerStyles.backgroundColor = 'transparent';
|
||||||
containerStyles.border = 'none';
|
containerStyles.border = 'none';
|
||||||
@ -131,13 +153,34 @@ export function PanelChrome({
|
|||||||
|
|
||||||
const testid = title ? selectors.components.Panels.Panel.title(title) : 'Panel';
|
const testid = title ? selectors.components.Panels.Panel.title(title) : 'Panel';
|
||||||
|
|
||||||
|
const collapsibleHeader = (
|
||||||
|
<h6 className={styles.title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clearButtonStyles}
|
||||||
|
onClick={toggleOpen}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={isOpen ? panelContentId : undefined}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={isOpen ? 'angle-down' : 'angle-right'}
|
||||||
|
aria-hidden={!!title}
|
||||||
|
aria-label={!title ? 'toggle collapse panel' : undefined}
|
||||||
|
/>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
|
||||||
const headerContent = (
|
const headerContent = (
|
||||||
<>
|
<>
|
||||||
{title && (
|
{collapsible
|
||||||
<h6 title={title} className={styles.title}>
|
? collapsibleHeader
|
||||||
{title}
|
: title && (
|
||||||
</h6>
|
<h6 title={title} className={styles.title}>
|
||||||
)}
|
{title}
|
||||||
|
</h6>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
|
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
|
||||||
<PanelDescription description={description} className={dragClassCancel} />
|
<PanelDescription description={description} className={dragClassCancel} />
|
||||||
@ -221,9 +264,15 @@ export function PanelChrome({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cx(styles.content, height === undefined && styles.containNone)} style={contentStyle}>
|
{isOpen && (
|
||||||
{typeof children === 'function' ? children(innerWidth, innerHeight) : children}
|
<div
|
||||||
</div>
|
id={panelContentId}
|
||||||
|
className={cx(styles.content, height === undefined && styles.containNone)}
|
||||||
|
style={contentStyle}
|
||||||
|
>
|
||||||
|
{typeof children === 'function' ? children(innerWidth, innerHeight) : children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -245,6 +294,7 @@ const getContentStyle = (
|
|||||||
padding: string,
|
padding: string,
|
||||||
theme: GrafanaTheme2,
|
theme: GrafanaTheme2,
|
||||||
headerHeight: number,
|
headerHeight: number,
|
||||||
|
isOpen: boolean,
|
||||||
height?: number,
|
height?: number,
|
||||||
width?: number
|
width?: number
|
||||||
) => {
|
) => {
|
||||||
@ -258,15 +308,19 @@ const getContentStyle = (
|
|||||||
innerWidth = width - panelPadding - panelBorder;
|
innerWidth = width - panelPadding - panelBorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentStyle: CSSProperties = {
|
|
||||||
padding: chromePadding,
|
|
||||||
};
|
|
||||||
|
|
||||||
let innerHeight = 0;
|
let innerHeight = 0;
|
||||||
if (height) {
|
if (height) {
|
||||||
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
innerHeight = headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentStyle: CSSProperties = {
|
||||||
|
padding: chromePadding,
|
||||||
|
};
|
||||||
|
|
||||||
return { contentStyle, innerWidth, innerHeight };
|
return { contentStyle, innerWidth, innerHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -341,6 +395,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
}),
|
}),
|
||||||
title: css({
|
title: css({
|
||||||
label: 'panel-title',
|
label: 'panel-title',
|
||||||
|
display: 'flex',
|
||||||
marginBottom: 0, // override default h6 margin-bottom
|
marginBottom: 0, // override default h6 margin-bottom
|
||||||
padding: theme.spacing(0, padding),
|
padding: theme.spacing(0, padding),
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@ -390,5 +445,17 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}),
|
}),
|
||||||
|
clearButtonStyles: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'transparent',
|
||||||
|
color: theme.colors.text.primary,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: theme.typography.h6.fontSize,
|
||||||
|
fontWeight: theme.typography.h6.fontWeight,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user