From 8dd66090bedbdea422ba19452acdf9c381940a2a Mon Sep 17 00:00:00 2001
From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Date: Thu, 27 Jul 2023 13:53:51 -0600
Subject: [PATCH] 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
---
.../components/PanelChrome/PanelChrome.mdx | 50 ++++++++-
.../PanelChrome/PanelChrome.story.tsx | 4 +
.../PanelChrome/PanelChrome.test.tsx | 19 +++-
.../components/PanelChrome/PanelChrome.tsx | 105 ++++++++++++++----
4 files changed, 157 insertions(+), 21 deletions(-)
diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx
index 72e16326947..9619330b571 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.mdx
@@ -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
}}
+
+### 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
+
+ {(innerwidth, innerheight) => {
+ return (
+
+ Content
+
+ );
+ }}
+
+```
+
+
+
+ {(innerwidth, innerheight) => {
+ return (
+
+ Content
+
+ );
+ }}
+
+
diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx
index d327a4b5aeb..e00bdd78023 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx
@@ -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: (
diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx
index 2c4d8255789..45e2535f89b 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx
@@ -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);
+});
diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
index 8e566b13f90..fe185263ee0 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
@@ -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 = (
+
+
+
+ {title}
+
+
+ );
+
const headerContent = (
<>
- {title && (
-
- {title}
-
- )}
+ {collapsible
+ ? collapsibleHeader
+ : title && (
+
+ {title}
+
+ )}
@@ -221,9 +264,15 @@ export function PanelChrome({
)}
-
- {typeof children === 'function' ? children(innerWidth, innerHeight) : children}
-
+ {isOpen && (
+
+ {typeof children === 'function' ? children(innerWidth, innerHeight) : children}
+
+ )}
);
}
@@ -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,
+ }),
};
};