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 = ( +
+ +
+ ); + 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, + }), }; };