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
This commit is contained in:
Torkel Ödegaard
2025-11-20 12:22:47 +01:00
committed by GitHub
parent 1d2426f880
commit 4637da8fcc
10 changed files with 860 additions and 0 deletions
@@ -0,0 +1,5 @@
import { Meta, ArgTypes } from '@storybook/blocks';
import { Sidebar } from './Sidebar';
Sidebar
When to use
@@ -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<StoryProps> = {
title: 'Overlays/Sidebar',
parameters: {
docs: {
page: mdx,
},
controls: {},
},
args: {
position: 'right',
},
argTypes: {
position: { control: { type: 'radio' }, options: ['right', 'left'] },
},
};
export const Example: StoryFn<StoryProps> = (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 (
<Box padding={2} backgroundColor={'canvas'} maxWidth={100} borderStyle={'solid'} borderColor={'weak'}>
<div className={containerStyle} {...contextValue.outerWrapperProps}>
<div className={gridStyle}>
{renderBox('A')}
{renderBox('B')}
{renderBox('C')}
{renderBox('D')}
{renderBox('E')}
{renderBox('F')}
{renderBox('G')}
</div>
<Sidebar contextValue={contextValue}>
{openPane === 'settings' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Settings" onClose={() => togglePane('')}>
<Button variant="secondary" size="sm">
Action
</Button>
</Sidebar.PaneHeader>
</Sidebar.OpenPane>
)}
{openPane === 'outline' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Outline" onClose={() => togglePane('')} />
</Sidebar.OpenPane>
)}
{openPane === 'add' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Add element" onClose={() => togglePane('')} />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
<Sidebar.Button
icon="plus"
title="Add"
tooltip="Add element"
active={openPane === 'add'}
onClick={() => togglePane('add')}
/>
<Sidebar.Button
icon="cog"
title="Settings"
active={openPane === 'settings'}
onClick={() => togglePane('settings')}
/>
<Sidebar.Button
icon="list-ui-alt"
title="Outline"
active={openPane === 'outline'}
onClick={() => togglePane('outline')}
/>
<Sidebar.Divider />
<Sidebar.Button icon="info-circle" title="Insights" />
<Sidebar.Button icon="code-branch" title="Integrations" />
</Sidebar.Toolbar>
</Sidebar>
</div>
</Box>
);
};
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 (
<Box padding={2} backgroundColor={'canvas'} maxWidth={100} borderStyle={'solid'} borderColor={'weak'}>
<div className={containerStyle}>
<div className={vizWrapper}>{renderBox('Visualization')}</div>
<Sidebar contextValue={contextValue}>
{openPane === 'queries' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Queries" />
</Sidebar.OpenPane>
)}
{openPane === 'transformations' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Transformations" />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
<Sidebar.Button
icon="database"
title="Queries"
active={openPane === 'queries'}
onClick={() => togglePane('queries')}
/>
<Sidebar.Button
icon="process"
title="Data"
tooltip="Data transformations"
active={openPane === 'transformations'}
onClick={() => togglePane('transformations')}
/>
<Sidebar.Button icon="bell" title="Alerts" />
</Sidebar.Toolbar>
</Sidebar>
</div>
</Box>
);
};
function renderBox(label: string) {
return (
<Box
backgroundColor={'primary'}
borderColor={'weak'}
borderStyle={'solid'}
justifyContent={'center'}
alignItems={'center'}
display={'flex'}
flex={1}
>
{label}
</Box>
);
}
export default meta;
@@ -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(<TestSetup />);
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 (
<div {...contextValue.outerWrapperProps} data-testid="sidebar-test-wrapper">
<Sidebar contextValue={contextValue}>
{openPane === 'settings' && (
<Sidebar.OpenPane>
<Sidebar.PaneHeader title="Settings" onClose={() => setOpenPane('')} />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
<Sidebar.Button icon="cog" title="Settings" onClick={() => setOpenPane('settings')} />
<Sidebar.Button icon="process" title="Data" tooltip="Data transformations" />
<Sidebar.Button icon="bell" title="Alerts" />
</Sidebar.Toolbar>
</Sidebar>
</div>
);
}
@@ -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 (
<SidebarContext.Provider value={contextValue}>
<div className={className} style={style}>
{!tabsMode && <SidebarResizer />}
{children}
</div>
</SidebarContext.Provider>
);
}
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 (
<div className={cx(styles.toolbar, context.compact && styles.toolbarIconsOnly)}>
{children}
<div className={styles.flexGrow} />
{context.hasOpenPane && (
<SidebarButton
icon={'web-section-alt'}
onClick={context.onDockChange}
title={context.isDocked ? t('grafana-ui.sidebar.undock', 'Undock') : t('grafana-ui.sidebar.dock', 'Dock')}
/>
)}
</div>
);
}
export function SidebarDivider() {
const styles = useStyles2(getStyles);
return <div className={styles.divider} />;
}
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 (
<div className={className} style={{ width: context.paneWidth }}>
{children}
</div>
);
}
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';
@@ -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 (
<Tooltip content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
<button className={buttonClass} aria-label={title} aria-expanded={active} type="button" onClick={onClick}>
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
</button>
</Tooltip>
);
}
function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) {
if (!icon) {
return null;
}
if (isIconName(icon)) {
return <Icon name={icon} size={compact ? `lg` : `lg`} />;
}
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%',
},
}),
};
};
@@ -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 (
<div className={styles.wrapper}>
{onClose && (
<IconButton
variant="secondary"
size="lg"
name="times"
onClick={onClose}
aria-label={t('grafana-ui.sidebar.close', 'Close')}
tooltip={t('grafana-ui.sidebar.close', 'Close')}
/>
)}
<Text weight="medium" variant="h6" truncate data-testid="sidebar-pane-header-title">
{title}
</Text>
<div className={styles.flexGrow} />
{children}
</div>
);
}
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,
}),
};
};
@@ -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<HTMLDivElement | null>(null);
const dragStart = useRef<number | null>(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<HTMLDivElement>) => {
if (resizerRef.current === null) {
return;
}
resizerRef.current.setPointerCapture(e.pointerId);
dragStart.current = e.clientX;
},
[resizerRef]
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
dragStart.current = null;
},
[dragStart]
);
return (
<div
ref={resizerRef}
className={styles[context.position]}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
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}`,
},
}),
};
};
@@ -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<HTMLDivElement>;
paneWidth: number;
bottomMargin: number;
edgeMargin: number;
contentMargin: number;
onDockChange: () => void;
onResize: (diff: number) => void;
}
export const SidebarContext: React.Context<SidebarContextValue | undefined> = 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,
};
}
+1
View File
@@ -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 {
+5
View File
@@ -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"
},