From 4637da8fcc680405b77a76cca8d5fe7daec36fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 20 Nov 2025 12:22:47 +0100 Subject: [PATCH] Sidebar: A new reusable component for side toolbars and panes (#114141) * initial wip * fixes * Switching to context * New button design * Overflow ellipsis * Changes to button * initial resize work * resize progress * Update * no reszier in tabs mode * fixing text truncte, and tabs right position * Minor story tweaks * added unit test * added unit test * Minor change * minor story fix * Fixes --- .../src/components/Sidebar/Sidebar.mdx | 5 + .../src/components/Sidebar/Sidebar.story.tsx | 211 ++++++++++++++++++ .../src/components/Sidebar/Sidebar.test.tsx | 51 +++++ .../src/components/Sidebar/Sidebar.tsx | 167 ++++++++++++++ .../src/components/Sidebar/SidebarButton.tsx | 159 +++++++++++++ .../components/Sidebar/SidebarPaneHeader.tsx | 55 +++++ .../src/components/Sidebar/SidebarResizer.tsx | 93 ++++++++ .../src/components/Sidebar/useSidebar.tsx | 113 ++++++++++ packages/grafana-ui/src/index.ts | 1 + public/locales/en-US/grafana.json | 5 + 10 files changed, 860 insertions(+) create mode 100644 packages/grafana-ui/src/components/Sidebar/Sidebar.mdx create mode 100644 packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/Sidebar.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx create mode 100644 packages/grafana-ui/src/components/Sidebar/useSidebar.tsx diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.mdx b/packages/grafana-ui/src/components/Sidebar/Sidebar.mdx new file mode 100644 index 00000000000..e27c4aecd1b --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.mdx @@ -0,0 +1,5 @@ +import { Meta, ArgTypes } from '@storybook/blocks'; +import { Sidebar } from './Sidebar'; + +Sidebar +When to use diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx new file mode 100644 index 00000000000..8590f91a8b4 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx @@ -0,0 +1,211 @@ +import { css } from '@emotion/css'; +import { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { Button } from '../Button/Button'; +import { Box } from '../Layout/Box/Box'; + +import { Sidebar, SidebarPosition, useSidebar } from './Sidebar'; +import mdx from './Sidebar.mdx'; + +interface StoryProps { + position: SidebarPosition; +} + +const meta: Meta = { + title: 'Overlays/Sidebar', + parameters: { + docs: { + page: mdx, + }, + controls: {}, + }, + args: { + position: 'right', + }, + argTypes: { + position: { control: { type: 'radio' }, options: ['right', 'left'] }, + }, +}; + +export const Example: StoryFn = (args) => { + const [openPane, setOpenPane] = useState(''); + + const containerStyle = css({ + flexGrow: 1, + height: '600px', + display: 'flex', + flexDirection: 'column', + position: 'relative', + overflow: 'hidden', + }); + + const gridStyle = css({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gridAutoRows: '300px', + gap: '8px', + flexGrow: 1, + overflow: 'auto', + }); + + const togglePane = (pane: string) => { + if (openPane === pane) { + setOpenPane(''); + } else { + setOpenPane(pane); + } + }; + + const contextValue = useSidebar({ + hasOpenPane: !!openPane, + position: args.position, + bottomMargin: 0, + edgeMargin: 0, + }); + + return ( + +
+
+ {renderBox('A')} + {renderBox('B')} + {renderBox('C')} + {renderBox('D')} + {renderBox('E')} + {renderBox('F')} + {renderBox('G')} +
+ + {openPane === 'settings' && ( + + togglePane('')}> + + + + )} + {openPane === 'outline' && ( + + togglePane('')} /> + + )} + {openPane === 'add' && ( + + togglePane('')} /> + + )} + + togglePane('add')} + /> + + togglePane('settings')} + /> + togglePane('outline')} + /> + + + + + +
+
+ ); +}; + +export const VerticalTabs: StoryFn = (args) => { + const [openPane, setOpenPane] = useState('queries'); + + const togglePane = (pane: string) => { + setOpenPane(pane); + }; + + const containerStyle = css({ + flexGrow: 1, + height: '600px', + display: 'flex', + flexDirection: 'column', + position: 'relative', + overflow: 'hidden', + gap: '16px', + }); + + const vizWrapper = css({ + height: '30%', + display: 'flex', + }); + + const contextValue = useSidebar({ + position: args.position, + tabsMode: true, + edgeMargin: 0, + }); + + return ( + +
+
{renderBox('Visualization')}
+ + {openPane === 'queries' && ( + + + + )} + {openPane === 'transformations' && ( + + + + )} + + togglePane('queries')} + /> + togglePane('transformations')} + /> + + + +
+
+ ); +}; + +function renderBox(label: string) { + return ( + + {label} + + ); +} + +export default meta; diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx new file mode 100644 index 00000000000..187fbc90c4b --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import React, { act } from 'react'; + +import { Sidebar, useSidebar } from './Sidebar'; + +describe('Sidebar', () => { + it('should render sidebar', async () => { + render(); + + act(() => screen.getByLabelText('Settings').click()); + + // Verify pane is open + expect(screen.getByTestId('sidebar-pane-header-title')).toBeInTheDocument(); + + act(() => screen.getByLabelText('Dock').click()); + + // Verify wrapper pushes content when docked + const wrapper = screen.getByTestId('sidebar-test-wrapper'); + expect(wrapper).toHaveStyle('padding-right: 352px'); + + // Close pane + act(() => screen.getByLabelText('Close').click()); + // Verify pane is closed + expect(screen.queryByTestId('sidebar-pane-header-title')).not.toBeInTheDocument(); + }); +}); + +function TestSetup() { + const [openPane, setOpenPane] = React.useState(''); + const contextValue = useSidebar({ + position: 'right', + hasOpenPane: openPane !== '', + }); + + return ( +
+ + {openPane === 'settings' && ( + + setOpenPane('')} /> + + )} + + setOpenPane('settings')} /> + + + + +
+ ); +} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000000..2d9b407a1ad --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,167 @@ +import { css, cx } from '@emotion/css'; +import { ReactNode, useContext } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; + +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'; + +export interface Props { + children?: ReactNode; + contextValue: SidebarContextValue; +} + +export function SidebarComp({ children, contextValue }: Props) { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const { isDocked, position, tabsMode, hasOpenPane, edgeMargin, bottomMargin } = contextValue; + + const className = cx({ + [styles.container]: true, + [styles.undockedPaneOpen]: hasOpenPane && !isDocked, + [styles.containerLeft]: position === 'left', + [styles.containerTabsMode]: tabsMode, + }); + + const style = { [position]: theme.spacing(edgeMargin), bottom: theme.spacing(bottomMargin) }; + + return ( + +
+ {!tabsMode && } + {children} +
+
+ ); +} + +export interface SiderbarToolbarProps { + children?: ReactNode; +} + +export function SiderbarToolbar({ children }: SiderbarToolbarProps) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.Toolbar must be used within a Sidebar component'); + } + + return ( +
+ {children} +
+ {context.hasOpenPane && ( + + )} +
+ ); +} + +export function SidebarDivider() { + const styles = useStyles2(getStyles); + + return
; +} + +export interface SidebarOpenPaneProps { + children?: ReactNode; +} + +export function SidebarOpenPane({ children }: SidebarOpenPaneProps) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.OpenPane must be used within a Sidebar component'); + } + + const className = cx(styles.openPane, context.position === 'right' ? styles.openPaneRight : styles.openPaneLeft); + + return ( +
+ {children} +
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + position: 'absolute', + flexDirection: 'row', + flex: '1 1 0', + border: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + zIndex: theme.zIndex.navbarFixed, + bottom: 0, + top: 0, + right: 0, + }), + containerTabsMode: css({ + position: 'relative', + }), + containerLeft: css({ + right: 'unset', + flexDirection: 'row-reverse', + left: 0, + borderRadius: theme.shape.radius.default, + }), + undockedPaneOpen: css({ + boxShadow: theme.shadows.z3, + }), + toolbar: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: theme.spacing(1, 0), + flexGrow: 0, + gap: theme.spacing(1), + overflow: 'hidden', + width: theme.spacing(SIDE_BAR_WIDTH_WITH_TEXT), + }), + toolbarIconsOnly: css({ + width: theme.spacing(SIDE_BAR_WIDTH_ICON_ONLY), + }), + divider: css({ + height: '1px', + background: theme.colors.border.weak, + width: '100%', + }), + flexGrow: css({ + flexGrow: 1, + }), + openPane: css({ + width: '280px', + flexGrow: 1, + paddingBottom: theme.spacing(2), + }), + openPaneRight: css({ + borderRight: `1px solid ${theme.colors.border.weak}`, + }), + openPaneLeft: css({ + borderLeft: `1px solid ${theme.colors.border.weak}`, + }), + }; +}; + +export const Sidebar = Object.assign(SidebarComp, { + Toolbar: SiderbarToolbar, + Button: SidebarButton, + OpenPane: SidebarOpenPane, + Divider: SidebarDivider, + PaneHeader: SidebarPaneHeader, +}); + +export { type SidebarPosition, type SidebarContextValue, useSidebar } from './useSidebar'; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx new file mode 100644 index 00000000000..f436d9630f6 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx @@ -0,0 +1,159 @@ +import { css, cx } from '@emotion/css'; +import { useContext } from 'react'; + +import { GrafanaTheme2, IconName, isIconName } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; +import { getActiveButtonStyles } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; +import { Tooltip } from '../Tooltip/Tooltip'; + +import { SidebarContext } from './useSidebar'; + +export interface Props { + icon: IconName; + active?: boolean; + onClick?: () => void; + title: string; + tooltip?: string; +} + +export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.Button must be used within a Sidebar component'); + } + + const buttonClass = cx( + styles.button, + context.compact && styles.compact, + active && styles.active, + context.position === 'left' && styles.leftButton + ); + + return ( + + + + ); +} + +function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) { + if (!icon) { + return null; + } + + if (isIconName(icon)) { + return ; + } + + return icon; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + button: css({ + label: 'toolbar-button', + position: 'relative', + display: 'flex', + flexDirection: 'column', + minHeight: theme.spacing(theme.components.height.md), + padding: theme.spacing(0, 1), + width: '100%', + overflow: 'hidden', + // borderRadius: theme.shape.radius.sm, + lineHeight: `${theme.components.height.md * theme.spacing.gridSize - 2}px`, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.secondary, + background: 'transparent', + border: `none`, + + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + + '&:focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&[disabled], &:disabled': { + cursor: 'not-allowed', + opacity: theme.colors.action.disabledOpacity, + background: theme.colors.action.disabledBackground, + boxShadow: 'none', + + '&:hover': { + color: theme.colors.text.disabled, + background: theme.colors.action.disabledBackground, + boxShadow: 'none', + }, + }, + + '&:hover, &:focus-visible': { + color: theme.colors.text.primary, + background: theme.colors.action.hover, + }, + + '&:active': { + ...getActiveButtonStyles(theme.colors.secondary, 'solid'), + }, + }), + compact: css({ + height: theme.spacing(theme.components.height.md), + padding: theme.spacing(0, 1), + width: theme.spacing(5), + }), + active: css({ + color: theme.colors.text.primary, + background: theme.colors.action.selected, + '&::before': { + display: 'block', + content: '" "', + position: 'absolute', + right: 0, + top: 0, + height: '100%', + width: '2px', + borderRadius: theme.shape.radius.default, + backgroundImage: theme.colors.gradients.brandVertical, + }, + }), + buttonWrapper: css({ + display: 'flex', + flexDirection: 'column', + width: '100%', + whiteSpace: 'nowrap', + }), + iconWrapper: css({}), + title: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + textOverflow: 'ellipsis', + overflow: 'hidden', + textAlign: 'center', + whiteSpace: 'nowrap', + }), + titleActive: css({ + color: theme.colors.text.primary, + }), + leftButton: css({ + '&::before': { + right: 'unset', + left: 0, + top: 0, + height: '100%', + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx new file mode 100644 index 00000000000..75393777879 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/css'; +import { ReactNode } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { IconButton } from '../IconButton/IconButton'; +import { Text } from '../Text/Text'; + +export interface Props { + children?: ReactNode; + title: string; + onClose?: () => void; +} + +export function SidebarPaneHeader({ children, onClose, title }: Props) { + const styles = useStyles2(getStyles); + + return ( +
+ {onClose && ( + + )} + + {title} + +
+ {children} +
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(1.5), + height: theme.spacing(6), + gap: theme.spacing(1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + flexGrow: css({ + flexGrow: 1, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx new file mode 100644 index 00000000000..7a243f00b54 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx @@ -0,0 +1,93 @@ +import { css } from '@emotion/css'; +import { useCallback, useContext, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; + +import { SidebarContext } from './useSidebar'; + +export function SidebarResizer() { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + const resizerRef = useRef(null); + const dragStart = useRef(null); + + if (!context) { + throw new Error('Sidebar.Resizer must be used within a Sidebar component'); + } + + const { onResize, position } = context; + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (resizerRef.current === null) { + return; + } + + resizerRef.current.setPointerCapture(e.pointerId); + dragStart.current = e.clientX; + }, + [resizerRef] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (dragStart.current === null) { + return; + } + + const diff = e.clientX - dragStart.current; + dragStart.current = e.clientX; + + onResize(position === 'right' ? -diff : diff); + }, + [dragStart, onResize, position] + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + dragStart.current = null; + }, + [dragStart] + ); + + return ( +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + right: css({ + position: 'absolute', + width: theme.spacing.gridSize, + left: -theme.spacing.gridSize, + top: theme.shape.radius.default, + bottom: theme.shape.radius.default, + cursor: 'col-resize', + zIndex: 1, + '&:hover': { + borderRight: `1px solid ${theme.colors.primary.border}`, + }, + }), + left: css({ + position: 'absolute', + width: theme.spacing.gridSize, + right: -theme.spacing.gridSize, + top: theme.shape.radius.default, + bottom: theme.shape.radius.default, + cursor: 'col-resize', + zIndex: 1, + '&:hover': { + borderLeft: `1px solid ${theme.colors.primary.border}`, + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx new file mode 100644 index 00000000000..03d04b2f81a --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx @@ -0,0 +1,113 @@ +import { clamp } from 'lodash'; +import React, { useCallback } from 'react'; + +import { useTheme2 } from '../../themes/ThemeContext'; + +export type SidebarPosition = 'left' | 'right'; + +export interface SidebarContextValue { + isDocked: boolean; + position: SidebarPosition; + compact: boolean; + hasOpenPane?: boolean; + tabsMode?: boolean; + outerWrapperProps: React.HTMLAttributes; + paneWidth: number; + bottomMargin: number; + edgeMargin: number; + contentMargin: number; + onDockChange: () => void; + onResize: (diff: number) => void; +} + +export const SidebarContext: React.Context = React.createContext< + SidebarContextValue | undefined +>(undefined); + +export interface UseSideBarOptions { + hasOpenPane?: boolean; + position?: SidebarPosition; + tabsMode?: boolean; + compactDefault?: boolean; + /** defaults to 2 grid units (16px) */ + bottomMargin?: number; + /** defaults to 2 grid units (16px) */ + edgeMargin?: number; + /** defaults to 2 grid units (16px) */ + contentMargin?: number; +} + +export const SIDE_BAR_WIDTH_ICON_ONLY = 5; +export const SIDE_BAR_WIDTH_WITH_TEXT = 8; + +export function useSidebar({ + hasOpenPane, + position = 'right', + tabsMode, + compactDefault = true, + bottomMargin = 2, + edgeMargin = 2, + contentMargin = 2, +}: UseSideBarOptions): SidebarContextValue { + const theme = useTheme2(); + const [isDocked, setIsDocked] = React.useState(false); + const [paneWidth, setPaneWidth] = React.useState(280); + const [compact, setCompact] = React.useState(compactDefault); + // Used to accumulate drag distance to know when to change compact mode + const [_, setCompactDrag] = React.useState(0); + + const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []); + + const prop = position === 'right' ? 'paddingRight' : 'paddingLeft'; + const toolbarWidth = + ((compact ? SIDE_BAR_WIDTH_ICON_ONLY : SIDE_BAR_WIDTH_WITH_TEXT) + edgeMargin + contentMargin) * + theme.spacing.gridSize; + + const outerWrapperProps = { + style: { + [prop]: isDocked && hasOpenPane ? paneWidth + toolbarWidth : toolbarWidth, + }, + }; + + const onResize = useCallback( + (diff: number) => { + setPaneWidth((prevWidth) => { + // If no pane is open we use the resize action to toggle compact mode (button text visibility) + if (!hasOpenPane) { + setCompactDrag((prevDrag) => { + const newDrag = prevDrag + diff; + if (newDrag < -20 && !compact) { + setCompact(true); + return 0; + } else if (newDrag > 20 && compact) { + setCompact(false); + return 0; + } + + return newDrag; + }); + + return prevWidth; + } + + return clamp(prevWidth + diff, 100, 500); + }); + }, + [hasOpenPane, compact] + ); + + return { + isDocked, + onDockChange, + onResize, + outerWrapperProps, + position, + compact, + hasOpenPane, + tabsMode, + paneWidth, + edgeMargin, + bottomMargin, + contentMargin, + }; +} diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index b46ce1d33bb..e567e3dd561 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -466,6 +466,7 @@ export { RunnerPlugin } from './slate-plugins/runner'; export { SelectionShortcutsPlugin } from './slate-plugins/selection_shortcuts'; export { SlatePrism, type Token } from './slate-plugins/slate-prism'; export { SuggestionsPlugin } from './slate-plugins/suggestions'; +export { Sidebar, useSidebar, type SidebarPosition, type SidebarContextValue } from './components/Sidebar/Sidebar'; // @deprecated import from @grafana/schema export { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4492a771832..28512178dd7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8995,6 +8995,11 @@ "series-color-picker-popover": { "y-axis-usage": "Use right y-axis" }, + "sidebar": { + "close": "Close", + "dock": "Dock", + "undock": "Undock" + }, "slider": { "drag-handle-aria-label": "Use arrow keys to change the value" },