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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user