mirror of
https://github.com/grafana/grafana.git
synced 2025-02-13 00:55:47 -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 { Meta } from '@storybook/addon-docs/blocks';
|
||||
import { Meta, Preview } from '@storybook/addon-docs/blocks';
|
||||
import { PanelChrome } from './PanelChrome';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
@ -504,3 +504,51 @@ Component used for rendering content wrapped in the same style as grafana panels
|
||||
}}
|
||||
</PanelChrome>
|
||||
</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', {
|
||||
title: 'Panel with action link',
|
||||
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 { 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();
|
||||
});
|
||||
|
||||
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 React, { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import React, { CSSProperties, ReactElement, ReactNode, useId } from 'react';
|
||||
import { useMeasure, useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@ -21,17 +21,16 @@ import { TitleItem } from './TitleItem';
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type PanelChromeProps = FixedDimensions | AutoSize;
|
||||
export type PanelChromeProps = (AutoSize | FixedDimensions) & (Collapsible | HoverHeader);
|
||||
|
||||
interface BaseProps {
|
||||
padding?: PanelPadding;
|
||||
hoverHeaderOffset?: number;
|
||||
title?: string;
|
||||
description?: string | (() => string);
|
||||
titleItems?: ReactNode;
|
||||
menu?: ReactElement | (() => ReactElement);
|
||||
dragClass?: string;
|
||||
dragClassCancel?: string;
|
||||
hoverHeader?: boolean;
|
||||
/**
|
||||
* Use only to indicate loading or streaming data in the panel.
|
||||
* Any other values of loadingState are ignored.
|
||||
@ -70,6 +69,18 @@ interface AutoSize extends BaseProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface Collapsible {
|
||||
collapsible: boolean;
|
||||
hoverHeader?: never;
|
||||
hoverHeaderOffset?: never;
|
||||
}
|
||||
|
||||
interface HoverHeader {
|
||||
collapsible?: never;
|
||||
hoverHeader?: boolean;
|
||||
hoverHeaderOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -99,9 +110,13 @@ export function PanelChrome({
|
||||
actions,
|
||||
onCancelQuery,
|
||||
onOpenMenu,
|
||||
collapsible = false,
|
||||
}: PanelChromeProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const panelContentId = useId();
|
||||
|
||||
const [isOpen, toggleOpen] = useToggle(true);
|
||||
|
||||
const hasHeader = !hoverHeader;
|
||||
|
||||
@ -109,14 +124,21 @@ export function PanelChrome({
|
||||
const showOnHoverClass = 'show-on-hover';
|
||||
|
||||
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 = {
|
||||
height: headerHeight,
|
||||
cursor: dragClass ? 'move' : 'auto',
|
||||
};
|
||||
|
||||
const containerStyles: CSSProperties = { width, height };
|
||||
const containerStyles: CSSProperties = { width, height: isOpen ? height : headerHeight };
|
||||
if (displayMode === 'transparent') {
|
||||
containerStyles.backgroundColor = 'transparent';
|
||||
containerStyles.border = 'none';
|
||||
@ -131,13 +153,34 @@ export function PanelChrome({
|
||||
|
||||
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 = (
|
||||
<>
|
||||
{title && (
|
||||
<h6 title={title} className={styles.title}>
|
||||
{title}
|
||||
</h6>
|
||||
)}
|
||||
{collapsible
|
||||
? collapsibleHeader
|
||||
: title && (
|
||||
<h6 title={title} className={styles.title}>
|
||||
{title}
|
||||
</h6>
|
||||
)}
|
||||
|
||||
<div className={cx(styles.titleItems, dragClassCancel)} data-testid="title-items-container">
|
||||
<PanelDescription description={description} className={dragClassCancel} />
|
||||
@ -221,9 +264,15 @@ export function PanelChrome({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cx(styles.content, height === undefined && styles.containNone)} style={contentStyle}>
|
||||
{typeof children === 'function' ? children(innerWidth, innerHeight) : children}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
id={panelContentId}
|
||||
className={cx(styles.content, height === undefined && styles.containNone)}
|
||||
style={contentStyle}
|
||||
>
|
||||
{typeof children === 'function' ? children(innerWidth, innerHeight) : children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -245,6 +294,7 @@ const getContentStyle = (
|
||||
padding: string,
|
||||
theme: GrafanaTheme2,
|
||||
headerHeight: number,
|
||||
isOpen: boolean,
|
||||
height?: number,
|
||||
width?: number
|
||||
) => {
|
||||
@ -258,15 +308,19 @@ const getContentStyle = (
|
||||
innerWidth = width - panelPadding - panelBorder;
|
||||
}
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
padding: chromePadding,
|
||||
};
|
||||
|
||||
let innerHeight = 0;
|
||||
if (height) {
|
||||
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
innerHeight = headerHeight;
|
||||
}
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
padding: chromePadding,
|
||||
};
|
||||
|
||||
return { contentStyle, innerWidth, innerHeight };
|
||||
};
|
||||
|
||||
@ -341,6 +395,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
}),
|
||||
title: css({
|
||||
label: 'panel-title',
|
||||
display: 'flex',
|
||||
marginBottom: 0, // override default h6 margin-bottom
|
||||
padding: theme.spacing(0, padding),
|
||||
textOverflow: 'ellipsis',
|
||||
@ -390,5 +445,17 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'flex',
|
||||
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