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:
Haris Rozajac 2023-07-27 13:53:51 -06:00 committed by GitHub
parent 5707f512be
commit 8dd66090be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 157 additions and 21 deletions

View File

@ -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>

View File

@ -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: (

View File

@ -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);
});

View File

@ -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,
}),
};
};