mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 03:34:15 -06:00
Navigation: Add responsive behaviour to ToolbarButtonRow
(#53739)
* hacky first attempt * slightly cleaner... * behaviour mostly working... * remove unnecessary wrapper * css tweaks * much cleaner implementation with intersectionobserver * set style props directly on children * separate story, integrate when toggle is off * improve story, integrate when toggle is on * remove styles from DashNavTimeControls * mock IntersectionObserver for all unit tests * prettier * don't use dropdown anymore * add some basic documentation * add right alignment to scenes toolbarbuttonrow * just use the react children api to prevent duplicating children
This commit is contained in:
parent
b51167e89a
commit
211c9991c5
@ -1821,9 +1821,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Tooltip/Tooltip.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
|
@ -4,7 +4,7 @@ import React, { FC, ReactNode } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { Link } from '..';
|
||||
import { Link, ToolbarButtonRow } from '..';
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
import { IconName } from '../../types';
|
||||
@ -132,15 +132,7 @@ export const PageToolbar: FC<Props> = React.memo(
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
{React.Children.toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, index) => {
|
||||
return (
|
||||
<div className={styles.actionWrapper} key={index}>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ToolbarButtonRow alignment="right">{React.Children.toArray(children).filter(Boolean)}</ToolbarButtonRow>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -161,14 +153,13 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
align-items: center;
|
||||
background: ${theme.colors.background.canvas};
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: ${theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(1.5, 2)};
|
||||
`,
|
||||
leftWrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
pageIcon: css`
|
||||
display: none;
|
||||
@ -190,7 +181,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
`,
|
||||
navElement: css`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
max-width: calc(100vw - 78px);
|
||||
`,
|
||||
@ -224,9 +214,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: unset;
|
||||
}
|
||||
`,
|
||||
actionWrapper: css`
|
||||
padding: ${spacing(0.5, 0, 0.5, 1)};
|
||||
`,
|
||||
leftActionItem: css`
|
||||
display: none;
|
||||
${theme.breakpoints.up('md')} {
|
||||
|
@ -62,13 +62,12 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const buttonStyles = cx(
|
||||
'toolbar-button',
|
||||
{
|
||||
[styles.button]: true,
|
||||
[styles.buttonFullWidth]: fullWidth,
|
||||
[styles.narrow]: narrow,
|
||||
},
|
||||
(styles as any)[variant],
|
||||
styles[variant],
|
||||
className
|
||||
);
|
||||
|
||||
@ -140,7 +139,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
const defaultTopNav = css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text.primary};
|
||||
|
@ -0,0 +1,25 @@
|
||||
import { Meta, Props } from '@storybook/addon-docs/blocks';
|
||||
import { ToolbarButtonRow } from './ToolbarButton';
|
||||
|
||||
# ToolbarButtonRow
|
||||
|
||||
A container for multiple `ToolbarButton`s. Provides automatic overflow behaviour when the buttons no longer fit in the container.
|
||||
|
||||
## Usage
|
||||
|
||||
This example shows how to use several buttons in a `ToolbarButtonRow`.
|
||||
|
||||
```jsx
|
||||
<ToolbarButtonRow>
|
||||
<ToolbarButton variant="default" iconOnly={false} isOpen={false}>
|
||||
Last 6 hours
|
||||
</ToolbarButton>
|
||||
<ButtonGroup>
|
||||
<ToolbarButton icon="search-minus" variant="default" />
|
||||
<ToolbarButton icon="search-plus" variant="default" />
|
||||
</ButtonGroup>
|
||||
<ToolbarButton icon="sync" isOpen={false} variant="primary" />
|
||||
</ToolbarButtonRow>
|
||||
```
|
||||
|
||||
<Props of={ToolbarButtonRow} />
|
@ -0,0 +1,40 @@
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
|
||||
import { ToolbarButton } from './ToolbarButton';
|
||||
import { ToolbarButtonRow } from './ToolbarButtonRow';
|
||||
import mdx from './ToolbarButtonRow.mdx';
|
||||
|
||||
const meta: ComponentMeta<typeof ToolbarButtonRow> = {
|
||||
title: 'Buttons/ToolbarButton/ToolbarButtonRow',
|
||||
component: ToolbarButtonRow,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
controls: {
|
||||
exclude: ['className'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Basic: ComponentStory<typeof ToolbarButtonRow> = (args) => {
|
||||
return (
|
||||
<DashboardStoryCanvas>
|
||||
<ToolbarButtonRow {...args}>
|
||||
<ToolbarButton>Just text</ToolbarButton>
|
||||
<ToolbarButton icon="sync" tooltip="Sync" />
|
||||
<ToolbarButton imgSrc="./grafana_icon.svg">With imgSrc</ToolbarButton>
|
||||
<ToolbarButton>Just text</ToolbarButton>
|
||||
<ToolbarButton icon="sync" tooltip="Sync" />
|
||||
<ToolbarButton imgSrc="./grafana_icon.svg">With imgSrc</ToolbarButton>
|
||||
</ToolbarButtonRow>
|
||||
</DashboardStoryCanvas>
|
||||
);
|
||||
};
|
||||
|
||||
export default meta;
|
@ -1,37 +1,132 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { useDialog } from '@react-aria/dialog';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
import React, { forwardRef, HTMLAttributes, useState, useRef, useLayoutEffect, createRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { useTheme2 } from '../../themes';
|
||||
|
||||
import { ToolbarButton } from './ToolbarButton';
|
||||
export interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
/** Determine flex-alignment of child buttons. Needed for overflow behaviour. */
|
||||
alignment?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const ToolbarButtonRow = forwardRef<HTMLDivElement, Props>(({ className, children, ...rest }, ref) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
export const ToolbarButtonRow = forwardRef<HTMLDivElement, Props>(
|
||||
({ alignment = 'left', className, children, ...rest }, ref) => {
|
||||
const [childVisibility, setChildVisibility] = useState<boolean[]>(
|
||||
Array(React.Children.toArray(children).length).fill(true)
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showOverflowItems, setShowOverflowItems] = useState(false);
|
||||
const overflowItemsRef = createRef<HTMLDivElement>();
|
||||
const { overlayProps } = useOverlay(
|
||||
{ onClose: () => setShowOverflowItems(false), isDismissable: true, isOpen: showOverflowItems },
|
||||
overflowItemsRef
|
||||
);
|
||||
const { dialogProps } = useDialog({}, overflowItemsRef);
|
||||
const theme = useTheme2();
|
||||
const overflowButtonOrder = alignment === 'left' ? childVisibility.indexOf(false) - 1 : childVisibility.length;
|
||||
const styles = getStyles(theme, overflowButtonOrder, alignment);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cx(styles.wrapper, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target instanceof HTMLElement && entry.target.parentNode) {
|
||||
const index = Array.prototype.indexOf.call(entry.target.parentNode.children, entry.target);
|
||||
setChildVisibility((prev) => {
|
||||
const newVisibility = [...prev];
|
||||
newVisibility[index] = entry.isIntersecting;
|
||||
return newVisibility;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 1,
|
||||
root: containerRef.current,
|
||||
}
|
||||
);
|
||||
if (containerRef.current) {
|
||||
Array.from(containerRef.current.children).forEach((item) => {
|
||||
intersectionObserver.observe(item);
|
||||
});
|
||||
}
|
||||
return () => intersectionObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cx(styles.container, className)} {...rest}>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<div
|
||||
style={{ order: index, visibility: childVisibility[index] ? 'visible' : 'hidden' }}
|
||||
className={styles.childWrapper}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
{childVisibility.includes(false) && (
|
||||
<>
|
||||
<ToolbarButton
|
||||
variant={showOverflowItems ? 'active' : 'default'}
|
||||
tooltip="Show more items"
|
||||
onClick={() => setShowOverflowItems(!showOverflowItems)}
|
||||
className={styles.overflowButton}
|
||||
icon="ellipsis-v"
|
||||
iconOnly
|
||||
narrow
|
||||
/>
|
||||
{showOverflowItems && (
|
||||
<FocusScope contain autoFocus>
|
||||
<div className={styles.overflowItems} ref={overflowItemsRef} {...overlayProps} {...dialogProps}>
|
||||
{React.Children.toArray(children).map((child, index) => !childVisibility[index] && child)}
|
||||
</div>
|
||||
</FocusScope>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToolbarButtonRow.displayName = 'ToolbarButtonRow';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment: Props['alignment']) => ({
|
||||
overflowButton: css`
|
||||
order: ${overflowButtonOrder};
|
||||
`,
|
||||
overflowItems: css`
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.background.primary};
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(1)};
|
||||
margin-top: ${theme.spacing(1)};
|
||||
max-width: 80vw;
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
width: max-content;
|
||||
z-index: ${theme.zIndex.sidemenu};
|
||||
`,
|
||||
container: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.spacing(1)};
|
||||
justify-content: ${alignment === 'left' ? 'flex-start' : 'flex-end'};
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
`,
|
||||
childWrapper: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
> .button-group,
|
||||
> .toolbar-button {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -24,6 +24,7 @@ export function getPageStyles(theme: GrafanaTheme2) {
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-scrollbar-content {
|
||||
|
@ -68,6 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
paddingLeft: theme.spacing(1),
|
||||
flexGrow: 1,
|
||||
gap: theme.spacing(0.5),
|
||||
minWidth: 0,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -36,17 +36,6 @@ const setup = () => {
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
beforeEach(() => {
|
||||
// IntersectionObserver isn't available in test environment
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
mockIntersectionObserver.mockReturnValue({
|
||||
observe: () => null,
|
||||
unobserve: () => null,
|
||||
disconnect: () => null,
|
||||
});
|
||||
window.IntersectionObserver = mockIntersectionObserver;
|
||||
});
|
||||
|
||||
it('should render component', async () => {
|
||||
setup();
|
||||
const sidemenu = await screen.findByTestId('sidemenu');
|
||||
|
@ -66,17 +66,6 @@ async function getTestContext(overrides: Partial<Props> = {}, subUrl = '', isMen
|
||||
}
|
||||
|
||||
describe('NavBarItem', () => {
|
||||
beforeEach(() => {
|
||||
// IntersectionObserver isn't available in test environment
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
mockIntersectionObserver.mockReturnValue({
|
||||
observe: () => null,
|
||||
unobserve: () => null,
|
||||
disconnect: () => null,
|
||||
});
|
||||
window.IntersectionObserver = mockIntersectionObserver;
|
||||
});
|
||||
|
||||
describe('when url property is not set', () => {
|
||||
it('then it renders the menu trigger as a button', async () => {
|
||||
await getTestContext();
|
||||
|
@ -99,7 +99,7 @@ const ui = {
|
||||
|
||||
describe('RuleEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
contextSrv.isEditor = true;
|
||||
contextSrv.hasEditPermissionInFolders = true;
|
||||
});
|
||||
|
@ -41,12 +41,18 @@ const renderRuleViewer = () => {
|
||||
});
|
||||
};
|
||||
describe('RuleViewer', () => {
|
||||
let mockCombinedRule: jest.MockedFn<typeof useCombinedRule>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCombinedRule = jest.mocked(useCombinedRule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockCombinedRule.mockReset();
|
||||
});
|
||||
|
||||
it('should render page with grafana alert', async () => {
|
||||
jest.mocked(useCombinedRule).mockReturnValue({
|
||||
mockCombinedRule.mockReturnValue({
|
||||
result: mockGrafanaRule as CombinedRule,
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
@ -60,7 +66,7 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
it('should render page with cloud alert', async () => {
|
||||
jest.mocked(useCombinedRule).mockReturnValue({
|
||||
mockCombinedRule.mockReturnValue({
|
||||
result: mockCloudRule as CombinedRule,
|
||||
loading: false,
|
||||
dispatched: true,
|
||||
|
@ -6,7 +6,15 @@ import { useLocation } from 'react-router-dom';
|
||||
import { locationUtil, textUtil } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar, useForceUpdate, Tag } from '@grafana/ui';
|
||||
import {
|
||||
ButtonGroup,
|
||||
ModalsController,
|
||||
ToolbarButton,
|
||||
PageToolbar,
|
||||
useForceUpdate,
|
||||
Tag,
|
||||
ToolbarButtonRow,
|
||||
} from '@grafana/ui';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator';
|
||||
import config from 'app/core/config';
|
||||
@ -322,7 +330,7 @@ export const DashNav = React.memo<Props>((props) => {
|
||||
<>
|
||||
{renderLeftActions()}
|
||||
<NavToolbarSeparator leftActionsSeparator />
|
||||
{renderRightActions()}
|
||||
<ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -4,7 +4,7 @@ import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { dateMath, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
|
||||
import { defaultIntervals, RefreshPicker, ToolbarButtonRow } from '@grafana/ui';
|
||||
import { defaultIntervals, RefreshPicker } from '@grafana/ui';
|
||||
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
@ -87,7 +87,7 @@ export class DashNavTimeControls extends Component<Props> {
|
||||
const hideIntervalPicker = dashboard.panelInEdit?.isEditing;
|
||||
|
||||
return (
|
||||
<ToolbarButtonRow>
|
||||
<>
|
||||
<TimePickerWithHistory
|
||||
value={timePickerValue}
|
||||
onChange={this.onChangeTimePicker}
|
||||
@ -119,7 +119,7 @@ export class DashNavTimeControls extends Component<Props> {
|
||||
offOptionLabelMsg={t({ id: 'dashboard.refresh-picker.off-label', message: 'Off' })}
|
||||
offOptionAriaLabelMsg={t({ id: 'dashboard.refresh-picker.off-arialabel', message: 'Turn off auto refresh' })}
|
||||
/>
|
||||
</ToolbarButtonRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,14 +3,7 @@ import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { DataSourceInstanceSettings, RawTimeRange } from '@grafana/data';
|
||||
import { config, DataSourcePicker, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
defaultIntervals,
|
||||
PageToolbar,
|
||||
RefreshPicker,
|
||||
SetInterval,
|
||||
ToolbarButton,
|
||||
ToolbarButtonRow,
|
||||
} from '@grafana/ui';
|
||||
import { defaultIntervals, PageToolbar, RefreshPicker, SetInterval, ToolbarButton } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@ -153,7 +146,7 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
||||
),
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<ToolbarButtonRow>
|
||||
<>
|
||||
{!splitted ? (
|
||||
<ToolbarButton tooltip="Split the pane" onClick={() => split()} icon="columns" disabled={isLive}>
|
||||
Split
|
||||
@ -216,7 +209,7 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
||||
}}
|
||||
</LiveTailControls>
|
||||
)}
|
||||
</ToolbarButtonRow>
|
||||
</>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
);
|
||||
|
@ -24,7 +24,7 @@ function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarButtonRow>
|
||||
<ToolbarButtonRow alignment="right">
|
||||
<TimePickerWithHistory
|
||||
value={timeRangeState}
|
||||
onChange={timeRange.onTimeRangeChange}
|
||||
|
@ -40,6 +40,14 @@ angular.module('grafana.directives', []);
|
||||
angular.module('grafana.filters', []);
|
||||
angular.module('grafana.routes', ['ngRoute']);
|
||||
|
||||
// Mock IntersectionObserver
|
||||
const mockIntersectionObserver = jest.fn().mockReturnValue({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
});
|
||||
global.IntersectionObserver = mockIntersectionObserver;
|
||||
|
||||
jest.mock('../app/core/core', () => ({
|
||||
...jest.requireActual('../app/core/core'),
|
||||
appEvents: testAppEvents,
|
||||
|
Loading…
Reference in New Issue
Block a user