Sidebar: Clickout side to close pane (#114523)

* Sidebar: Clickout side to close pane

* fixes

* fixing tests

* fix

* ignore clicks in portals

* fix e2e

* fix test

* Fix click away issues

* add missing file
This commit is contained in:
Torkel Ödegaard
2025-11-28 15:07:15 +01:00
committed by GitHub
parent ce72dc6224
commit b5eac7baad
14 changed files with 107 additions and 22 deletions
@@ -50,6 +50,7 @@ export const flows = {
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.dockToggle).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
.click();
@@ -61,6 +61,9 @@ export const versionedComponents = {
closePane: {
'12.4.0': 'data-testid Sidebar close pane',
},
dockToggle: {
'12.4.0': 'data-testid sidebar-dock-toggle',
},
},
EditPaneHeader: {
deleteButton: {
@@ -62,6 +62,7 @@ export const Example: StoryFn<StoryProps> = (args) => {
position: args.position,
bottomMargin: 0,
edgeMargin: 0,
onClosePane: () => setOpenPane(''),
});
return (
@@ -79,7 +80,7 @@ export const Example: StoryFn<StoryProps> = (args) => {
<Sidebar contextValue={contextValue}>
{openPane === 'settings' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Settings" onClose={() => togglePane('')}>
<Sidebar.PaneHeader title="Settings">
<Button variant="secondary" size="sm">
Action
</Button>
@@ -88,12 +89,12 @@ export const Example: StoryFn<StoryProps> = (args) => {
)}
{openPane === 'outline' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Outline" onClose={() => togglePane('')} />
<Sidebar.PaneHeader title="Outline" />
</Sidebar.OpenPane>
)}
{openPane === 'add' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Add element" onClose={() => togglePane('')} />
<Sidebar.PaneHeader title="Add element" />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
@@ -30,6 +30,7 @@ function TestSetup() {
const contextValue = useSidebar({
position: 'right',
hasOpenPane: openPane !== '',
onClosePane: () => setOpenPane(''),
});
return (
@@ -37,7 +38,7 @@ function TestSetup() {
<Sidebar contextValue={contextValue}>
{openPane === 'settings' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Settings" onClose={() => setOpenPane('')} />
<Sidebar.PaneHeader title="Settings" />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
@@ -2,14 +2,17 @@ import { css, cx } from '@emotion/css';
import { ReactNode, useContext } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { getPortalContainer } from '../Portal/Portal';
import { SidebarButton } from './SidebarButton';
import { SidebarPaneHeader } from './SidebarPaneHeader';
import { SidebarResizer } from './SidebarResizer';
import { SIDE_BAR_WIDTH_ICON_ONLY, SIDE_BAR_WIDTH_WITH_TEXT, SidebarContext, SidebarContextValue } from './useSidebar';
import { useCustomClickAway } from './useSidebarClickAway';
export interface Props {
children?: ReactNode;
@@ -30,9 +33,20 @@ export function SidebarComp({ children, contextValue }: Props) {
const style = { [position]: theme.spacing(edgeMargin), bottom: theme.spacing(bottomMargin) };
const ref = useCustomClickAway((evt) => {
const portalContainer = getPortalContainer();
// ignore clicks inside portal container
if (evt.target instanceof Node && portalContainer && portalContainer.contains(evt.target)) {
return;
}
if (!isDocked && hasOpenPane) {
contextValue.onClosePane?.();
}
});
return (
<SidebarContext.Provider value={contextValue}>
<div className={className} style={style}>
<div ref={ref} className={className} style={style}>
{!tabsMode && <SidebarResizer />}
{children}
</div>
@@ -61,7 +75,7 @@ export function SiderbarToolbar({ children }: SiderbarToolbarProps) {
icon={'web-section-alt'}
onClick={context.onToggleDock}
title={context.isDocked ? t('grafana-ui.sidebar.undock', 'Undock') : t('grafana-ui.sidebar.dock', 'Dock')}
data-testid="sidebar-dock-toggle"
data-testid={selectors.components.Sidebar.dockToggle}
/>
)}
</div>
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { ReactNode } from 'react';
import { ReactNode, useContext } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -9,23 +9,29 @@ import { useStyles2 } from '../../themes/ThemeContext';
import { IconButton } from '../IconButton/IconButton';
import { Text } from '../Text/Text';
import { SidebarContext } from './useSidebar';
export interface Props {
children?: ReactNode;
title: string;
onClose?: () => void;
}
export function SidebarPaneHeader({ children, onClose, title }: Props) {
export function SidebarPaneHeader({ children, title }: Props) {
const styles = useStyles2(getStyles);
const context = useContext(SidebarContext);
if (!context) {
throw new Error('SidebarPaneHeader must be used within a Sidebar');
}
return (
<div className={styles.wrapper}>
{onClose && (
{context.onClosePane && (
<IconButton
variant="secondary"
size="lg"
name="times"
onClick={onClose}
onClick={context.onClosePane}
aria-label={t('grafana-ui.sidebar.close', 'Close')}
tooltip={t('grafana-ui.sidebar.close', 'Close')}
data-testid={selectors.components.Sidebar.closePane}
@@ -18,6 +18,8 @@ export interface SidebarContextValue {
contentMargin: number;
onToggleDock: () => void;
onResize: (diff: number) => void;
/** Called when pane is closed or clicked outside of (in undocked mode) */
onClosePane?: () => void;
}
export const SidebarContext: React.Context<SidebarContextValue | undefined> = React.createContext<
@@ -35,6 +37,8 @@ export interface UseSideBarOptions {
edgeMargin?: number;
/** defaults to 2 grid units (16px) */
contentMargin?: number;
/** Called when pane is closed or clicked outside of (in undocked mode) */
onClosePane?: () => void;
}
export const SIDE_BAR_WIDTH_ICON_ONLY = 5;
@@ -48,6 +52,7 @@ export function useSidebar({
bottomMargin = 2,
edgeMargin = 2,
contentMargin = 2,
onClosePane,
}: UseSideBarOptions): SidebarContextValue {
const theme = useTheme2();
const [isDocked, setIsDocked] = React.useState(false);
@@ -109,5 +114,6 @@ export function useSidebar({
edgeMargin,
bottomMargin,
contentMargin,
onClosePane,
};
}
@@ -0,0 +1,32 @@
import React from 'react';
/**
* Cannot use the react-use useClickAway directly as it relies on mousedown event which is not ideal as the element selection uses pointerdown
* @param ref
* @param onClickAway
*/
export function useCustomClickAway(onClickAway: (evt: MouseEvent | TouchEvent) => void) {
const ref = React.useRef<HTMLDivElement>(null);
const refCb = React.useRef(onClickAway);
React.useLayoutEffect(() => {
refCb.current = onClickAway;
});
React.useEffect(() => {
const handler = (e: MouseEvent | TouchEvent) => {
const element = ref.current;
if (element && e.target instanceof Node && !element.contains(e.target)) {
refCb.current(e);
}
};
document.addEventListener('pointerdown', handler);
return () => {
document.removeEventListener('pointerdown', handler);
};
}, []);
return ref;
}
@@ -63,9 +63,9 @@ describe('DashboardEditPaneRenderer', () => {
act(() => screen.getByLabelText('Outline').click());
expect(await screen.findByTestId('sidebar-dock-toggle')).toBeInTheDocument();
expect(await screen.findByTestId(selectors.components.Sidebar.dockToggle)).toBeInTheDocument();
act(() => screen.getByTestId('sidebar-dock-toggle').click());
act(() => screen.getByTestId(selectors.components.Sidebar.dockToggle).click());
expect(scene.state.editPane.state.isDocked).toBe(true);
});
@@ -69,6 +69,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
hasOpenPane: Boolean(openPane),
contentMargin: 1,
position: 'right',
onClosePane: () => editPane.closePane(),
});
/**
@@ -5,7 +5,7 @@ import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { setPluginImportUtils } from '@grafana/runtime';
import { SceneVariableSet, VizPanel } from '@grafana/scenes';
import { ElementSelectionContext } from '@grafana/ui';
import { ElementSelectionContext, Sidebar, useSidebar } from '@grafana/ui';
import { DashboardScene } from '../scene/DashboardScene';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
@@ -86,6 +86,12 @@ function buildTestScene() {
return testScene;
}
function WrapSidebar({ children }: { children: React.ReactElement }) {
const sidebarContext = useSidebar({});
return <Sidebar contextValue={sidebarContext}>{children}</Sidebar>;
}
describe('DashboardOutline', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -101,7 +107,9 @@ describe('DashboardOutline', () => {
render(
<ElementSelectionContext.Provider value={scene.state.editPane.state.selectionContext}>
<DashboardOutline editPane={scene.state.editPane} isEditing={true} />
<WrapSidebar>
<DashboardOutline editPane={scene.state.editPane} isEditing={true} />
</WrapSidebar>
</ElementSelectionContext.Provider>
);
// select Row lvl 1
@@ -25,10 +25,7 @@ export function DashboardOutline({ editPane, isEditing }: Props) {
return (
<>
<Sidebar.PaneHeader
title={t('dashboard.outline.pane-header', 'Content outline')}
onClose={() => editPane.closePane()}
/>
<Sidebar.PaneHeader title={t('dashboard.outline.pane-header', 'Content outline')} />
<Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} isEditing={isEditing} editPane={editPane} depth={0} index={0} />
</Box>
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { SceneTimeRange } from '@grafana/scenes';
import { Sidebar, useSidebar } from '@grafana/ui';
import { DashboardScene } from '../scene/DashboardScene';
import { RowItem } from '../scene/layout-rows/RowItem';
@@ -53,6 +54,12 @@ const buildTestScene = (scene: DashboardScene) => {
return scene;
};
function WrapSidebar({ children }: { children: React.ReactElement }) {
const sidebarContext = useSidebar({});
return <Sidebar contextValue={sidebarContext}>{children}</Sidebar>;
}
describe('EditPaneHeader', () => {
const mockEditPane = {
state: { selection: null },
@@ -71,7 +78,11 @@ describe('EditPaneHeader', () => {
const elementSelection = new ElementSelection([['row-test', row.getRef()]]);
const editableElement = elementSelection.createSelectionElement()!;
render(<EditPaneHeader element={editableElement} editPane={mockEditPane} />);
render(
<WrapSidebar>
<EditPaneHeader element={editableElement} editPane={mockEditPane} />
</WrapSidebar>
);
await user.click(screen.getByTestId(selectors.components.EditPaneHeader.deleteButton));
expect(DashboardInteractions.trackRemoveRowClick).toHaveBeenCalled();
@@ -84,7 +95,11 @@ describe('EditPaneHeader', () => {
const elementSelection = new ElementSelection([['tab-test', tab.getRef()]]);
const editableElement = elementSelection.createSelectionElement()!;
render(<EditPaneHeader element={editableElement} editPane={mockEditPane} />);
render(
<WrapSidebar>
<EditPaneHeader element={editableElement} editPane={mockEditPane} />
</WrapSidebar>
);
await user.click(screen.getByTestId(selectors.components.EditPaneHeader.deleteButton));
expect(DashboardInteractions.trackRemoveTabClick).toHaveBeenCalled();
@@ -30,7 +30,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
};
return (
<Sidebar.PaneHeader title={elementInfo.typeName} onClose={() => editPane.closePane()}>
<Sidebar.PaneHeader title={elementInfo.typeName}>
<Stack direction="row" gap={1}>
{element.renderActions && element.renderActions()}
{(onCopy || onDuplicate) && (