From b5eac7baadbbb5b9a17574d0d69805d9a22dfe00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 28 Nov 2025 15:07:15 +0100 Subject: [PATCH] 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 --- e2e-playwright/dashboard-new-layouts/utils.ts | 1 + .../src/selectors/components.ts | 3 ++ .../src/components/Sidebar/Sidebar.story.tsx | 7 ++-- .../src/components/Sidebar/Sidebar.test.tsx | 3 +- .../src/components/Sidebar/Sidebar.tsx | 18 +++++++++-- .../components/Sidebar/SidebarPaneHeader.tsx | 16 +++++++--- .../src/components/Sidebar/useSidebar.tsx | 6 ++++ .../components/Sidebar/useSidebarClickAway.ts | 32 +++++++++++++++++++ .../DashboardEditPaneRenderer.test.tsx | 4 +-- .../edit-pane/DashboardEditPaneSplitter.tsx | 1 + .../edit-pane/DashboardOutline.test.tsx | 12 +++++-- .../edit-pane/DashboardOutline.tsx | 5 +-- .../edit-pane/EditPaneHeader.test.tsx | 19 +++++++++-- .../edit-pane/EditPaneHeader.tsx | 2 +- 14 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index 89f4de0eec3..83508063ef5 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -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(); diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 36d0088b258..c643a0d7f2c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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: { diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx index 8590f91a8b4..175e792d451 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx @@ -62,6 +62,7 @@ export const Example: StoryFn = (args) => { position: args.position, bottomMargin: 0, edgeMargin: 0, + onClosePane: () => setOpenPane(''), }); return ( @@ -79,7 +80,7 @@ export const Example: StoryFn = (args) => { {openPane === 'settings' && ( - togglePane('')}> + @@ -88,12 +89,12 @@ export const Example: StoryFn = (args) => { )} {openPane === 'outline' && ( - togglePane('')} /> + )} {openPane === 'add' && ( - togglePane('')} /> + )} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx index 187fbc90c4b..5e5116b3ada 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx @@ -30,6 +30,7 @@ function TestSetup() { const contextValue = useSidebar({ position: 'right', hasOpenPane: openPane !== '', + onClosePane: () => setOpenPane(''), }); return ( @@ -37,7 +38,7 @@ function TestSetup() { {openPane === 'settings' && ( - setOpenPane('')} /> + )} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx index 8c1bf784e53..311e11f390c 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx @@ -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 ( -
+
{!tabsMode && } {children}
@@ -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} /> )}
diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx index 42fe30b083c..e1e5e0bca59 100644 --- a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx +++ b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx @@ -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 (
- {onClose && ( + {context.onClosePane && ( void; onResize: (diff: number) => void; + /** Called when pane is closed or clicked outside of (in undocked mode) */ + onClosePane?: () => void; } export const SidebarContext: React.Context = 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, }; } diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts b/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts new file mode 100644 index 00000000000..5f1f2ccfb89 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts @@ -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(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; +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx index 41c81222661..d7f5c73280d 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx @@ -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); }); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index ce7df7a460b..59af972e3b0 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -69,6 +69,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls hasOpenPane: Boolean(openPane), contentMargin: 1, position: 'right', + onClosePane: () => editPane.closePane(), }); /** diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx index 7b461551773..f743f0a3ae7 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx @@ -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 {children}; +} + describe('DashboardOutline', () => { afterEach(() => { jest.clearAllMocks(); @@ -101,7 +107,9 @@ describe('DashboardOutline', () => { render( - + + + ); // select Row lvl 1 diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 97e17c34780..de80db73254 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -25,10 +25,7 @@ export function DashboardOutline({ editPane, isEditing }: Props) { return ( <> - editPane.closePane()} - /> + diff --git a/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.test.tsx b/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.test.tsx index 627ceb96d3b..e3dc190a073 100644 --- a/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.test.tsx +++ b/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.test.tsx @@ -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 {children}; +} + 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(); + render( + + + + ); 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(); + render( + + + + ); await user.click(screen.getByTestId(selectors.components.EditPaneHeader.deleteButton)); expect(DashboardInteractions.trackRemoveTabClick).toHaveBeenCalled(); diff --git a/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.tsx b/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.tsx index ea14464fc64..2f096293171 100644 --- a/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.tsx +++ b/public/app/features/dashboard-scene/edit-pane/EditPaneHeader.tsx @@ -30,7 +30,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) { }; return ( - editPane.closePane()}> + {element.renderActions && element.renderActions()} {(onCopy || onDuplicate) && (