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:
Ashley Harrison 2022-08-24 11:19:36 +01:00 committed by GitHub
parent b51167e89a
commit 211c9991c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 225 additions and 86 deletions

View File

@ -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"],

View File

@ -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')} {

View File

@ -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};

View File

@ -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} />

View File

@ -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;

View File

@ -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;
}
}
`,
});

View File

@ -24,6 +24,7 @@ export function getPageStyles(theme: GrafanaTheme2) {
flex-grow: 1;
height: 100%;
flex: 1 1 0;
min-width: 0;
}
.page-scrollbar-content {

View File

@ -68,6 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => {
paddingLeft: theme.spacing(1),
flexGrow: 1,
gap: theme.spacing(0.5),
minWidth: 0,
}),
};
};

View File

@ -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');

View File

@ -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();

View File

@ -99,7 +99,7 @@ const ui = {
describe('RuleEditor', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
contextSrv.isEditor = true;
contextSrv.hasEditPermissionInFolders = true;
});

View File

@ -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,

View File

@ -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>
</>
}
/>

View File

@ -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>
</>
);
}
}

View File

@ -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>
);

View File

@ -24,7 +24,7 @@ function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>
}
return (
<ToolbarButtonRow>
<ToolbarButtonRow alignment="right">
<TimePickerWithHistory
value={timeRangeState}
onChange={timeRange.onTimeRangeChange}

View File

@ -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,