Menu: Improvements to menu component (#52686)

This commit is contained in:
Torkel Ödegaard 2022-07-25 16:54:58 +02:00 committed by GitHub
parent da5bc5bde7
commit feaa037a19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 210 additions and 112 deletions

View File

@ -0,0 +1,34 @@
import { Meta, Props } from '@storybook/addon-docs/blocks';
import { Overlay } from 'ol';
import { Menu } from './Menu';
<Meta title="MDX|Menu" component={Menu} />
# Menu
A simple menu component.
### When to use
When you need to display a list of actions or navigation options in a dropdown.
### Usage
```tsx
import { Dropdown, Menu, Button } from '@grafana/ui';
const menu = (
<Menu>
<Menu.Item label="Google" />
<Menu.Divider />
<Menu.Item label="Delete" icon="trash-alt" destructive />
</Menu>
);
return (
<Dropdown overlay={menu}>
<Button icon="bars" />
</Dropdown>
);
```
<Props of={Menu} />

View File

@ -1,94 +0,0 @@
import { Story, ComponentMeta } from '@storybook/react';
import React from 'react';
import { GraphContextMenuHeader } from '..';
import { StoryExample } from '../../utils/storybook/StoryExample';
import { VerticalGroup } from '../Layout/Layout';
import { Menu } from './Menu';
import { MenuGroup } from './MenuGroup';
import { MenuItem } from './MenuItem';
const meta: ComponentMeta<typeof Menu> = {
title: 'General/Menu',
component: Menu,
argTypes: {},
parameters: {
knobs: {
disabled: true,
},
controls: {
disabled: true,
},
actions: {
disabled: true,
},
},
};
export const Simple: Story = (args) => {
return (
<VerticalGroup>
<StoryExample name="Simple">
<Menu>
<MenuItem label="Google" icon="search-plus" />
<MenuItem label="Filter" icon="filter" />
<MenuItem label="History" icon="history" />
<MenuItem label="Active" icon="history" active />
<MenuItem label="Apps" icon="apps" />
</Menu>
</StoryExample>
<StoryExample name="With header & groups">
<Menu header={args.header} ariaLabel="Menu header">
<MenuGroup label="Group 1">
<MenuItem label="item1" icon="history" />
<MenuItem label="item2" icon="filter" />
</MenuGroup>
<MenuGroup label="Group 2">
<MenuItem label="item1" icon="history" />
</MenuGroup>
</Menu>
</StoryExample>
<StoryExample name="With submenu">
<Menu>
<MenuItem label="item1" icon="history" />
<MenuItem
label="item2"
icon="apps"
childItems={[
<MenuItem key="subitem1" label="subitem1" icon="history" />,
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
<MenuItem
key="subitem3"
label="subitem3"
icon="search-plus"
childItems={[
<MenuItem key="subitem1" label="subitem1" icon="history" />,
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
<MenuItem key="subitem3" label="subitem3" icon="search-plus" />,
]}
/>,
]}
/>
<MenuItem label="item3" icon="filter" />
</Menu>
</StoryExample>
</VerticalGroup>
);
};
Simple.args = {
header: (
<GraphContextMenuHeader
timestamp="2020-11-25 19:04:25"
seriesColor="#00ff00"
displayName="A-series"
displayValue={{
text: '128',
suffix: 'km/h',
}}
/>
),
};
export default meta;

View File

@ -0,0 +1,104 @@
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { GraphContextMenuHeader } from '..';
import { StoryExample } from '../../utils/storybook/StoryExample';
import { VerticalGroup } from '../Layout/Layout';
import { Menu } from './Menu';
import mdx from './Menu.mdx';
const meta: ComponentMeta<typeof Menu> = {
title: 'General/Menu',
component: Menu,
argTypes: {},
parameters: {
docs: {
page: mdx,
},
knobs: {
disabled: true,
},
controls: {
disabled: true,
},
actions: {
disabled: true,
},
},
};
export function Examples() {
return (
<VerticalGroup>
<StoryExample name="Plain">
<Menu>
<Menu.Item label="Google" />
<Menu.Item label="Filter" />
<Menu.Item label="Active" active />
<Menu.Item label="I am a link" url="http://google.com" target="_blank" />
<Menu.Item label="With destructive prop set" destructive />
</Menu>
</StoryExample>
<StoryExample name="With icons and a divider">
<Menu>
<Menu.Item label="Google" icon="search-plus" />
<Menu.Item label="Filter" icon="filter" />
<Menu.Item label="History" icon="history" />
<Menu.Divider />
<Menu.Item label="With destructive prop set" icon="trash-alt" destructive />
</Menu>
</StoryExample>
<StoryExample name="With header & groups">
<Menu
header={
<GraphContextMenuHeader
timestamp="2020-11-25 19:04:25"
seriesColor="#00ff00"
displayName="A-series"
displayValue={{
text: '128',
suffix: 'km/h',
}}
/>
}
ariaLabel="Menu header"
>
<Menu.Group label="Group 1">
<Menu.Item label="item1" icon="history" />
<Menu.Item label="item2" icon="filter" />
</Menu.Group>
<Menu.Group label="Group 2">
<Menu.Item label="item1" icon="history" />
</Menu.Group>
</Menu>
</StoryExample>
<StoryExample name="With submenu">
<Menu>
<Menu.Item label="item1" icon="history" />
<Menu.Item
label="item2"
icon="apps"
childItems={[
<Menu.Item key="subitem1" label="subitem1" icon="history" />,
<Menu.Item key="subitem2" label="subitem2" icon="apps" />,
<Menu.Item
key="subitem3"
label="subitem3"
icon="search-plus"
childItems={[
<Menu.Item key="subitem1" label="subitem1" icon="history" />,
<Menu.Item key="subitem2" label="subitem2" icon="apps" />,
<Menu.Item key="subitem3" label="subitem3" icon="search-plus" />,
]}
/>,
]}
/>
<Menu.Item label="item3" icon="filter" />
</Menu>
</StoryExample>
</VerticalGroup>
);
}
export default meta;

View File

@ -5,9 +5,11 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { MenuDivider } from './MenuDivider';
import { MenuGroup } from './MenuGroup';
import { MenuItem } from './MenuItem';
import { useMenuFocus } from './hooks'; import { useMenuFocus } from './hooks';
/** @internal */
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> { export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
/** React element rendered at the top of the menu */ /** React element rendered at the top of the menu */
header?: React.ReactNode; header?: React.ReactNode;
@ -18,8 +20,7 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
onKeyDown?: React.KeyboardEventHandler; onKeyDown?: React.KeyboardEventHandler;
} }
/** @internal */ const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => { ({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -44,9 +45,15 @@ export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
); );
} }
); );
Menu.displayName = 'Menu';
/** @internal */ MenuComp.displayName = 'Menu';
export const Menu = Object.assign(MenuComp, {
Item: MenuItem,
Divider: MenuDivider,
Group: MenuGroup,
});
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
header: css` header: css`
@ -58,6 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => {
box-shadow: ${theme.shadows.z3}; box-shadow: ${theme.shadows.z3};
display: inline-block; display: inline-block;
border-radius: ${theme.shape.borderRadius()}; border-radius: ${theme.shape.borderRadius()};
padding: ${theme.spacing(0.5, 0)};
`, `,
}; };
}; };

View File

@ -0,0 +1,21 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
export function MenuDivider() {
const styles = useStyles2(getStyles);
return <div className={styles.divider} />;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
divider: css({
height: 1,
backgroundColor: theme.colors.border.weak,
margin: theme.spacing(0.5, 0),
}),
};
};

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { ReactElement, useCallback, useMemo, useState, useRef, useImperativeHandle } from 'react'; import React, { ReactElement, useCallback, useState, useRef, useImperativeHandle } from 'react';
import { GrafanaTheme2, LinkTarget } from '@grafana/data'; import { GrafanaTheme2, LinkTarget } from '@grafana/data';
@ -35,9 +35,9 @@ export interface MenuItemProps<T = any> {
className?: string; className?: string;
/** Active */ /** Active */
active?: boolean; active?: boolean;
/** Show in destructive style (error color) */
destructive?: boolean;
tabIndex?: number; tabIndex?: number;
/** List of menu items for the subMenu */ /** List of menu items for the subMenu */
childItems?: Array<ReactElement<MenuItemProps>>; childItems?: Array<ReactElement<MenuItemProps>>;
} }
@ -55,6 +55,7 @@ export const MenuItem = React.memo(
onClick, onClick,
className, className,
active, active,
destructive,
childItems, childItems,
role = 'menuitem', role = 'menuitem',
tabIndex = -1, tabIndex = -1,
@ -71,12 +72,14 @@ export const MenuItem = React.memo(
setIsSubMenuOpen(false); setIsSubMenuOpen(false);
setIsActive(false); setIsActive(false);
}, []); }, []);
const hasSubMenu = useMemo(() => childItems && childItems.length > 0, [childItems]);
const Wrapper = hasSubMenu ? 'div' : url === undefined ? 'button' : 'a'; const hasSubMenu = childItems && childItems.length > 0;
const ItemElement = hasSubMenu ? 'div' : url === undefined ? 'button' : 'a';
const itemStyle = cx( const itemStyle = cx(
{ {
[styles.item]: true, [styles.item]: true,
[styles.activeItem]: isActive, [styles.active]: isActive,
[styles.destructive]: destructive,
}, },
className className
); );
@ -107,7 +110,7 @@ export const MenuItem = React.memo(
}; };
return ( return (
<Wrapper <ItemElement
target={target} target={target}
className={itemStyle} className={itemStyle}
rel={target === '_blank' ? 'noopener noreferrer' : undefined} rel={target === '_blank' ? 'noopener noreferrer' : undefined}
@ -144,13 +147,13 @@ export const MenuItem = React.memo(
close={closeSubMenu} close={closeSubMenu}
/> />
)} )}
</Wrapper> </ItemElement>
); );
}) })
); );
MenuItem.displayName = 'MenuItem'; MenuItem.displayName = 'MenuItem';
/** @internal */
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
item: css` item: css`
@ -159,7 +162,9 @@ const getStyles = (theme: GrafanaTheme2) => {
white-space: nowrap; white-space: nowrap;
color: ${theme.colors.text.primary}; color: ${theme.colors.text.primary};
display: flex; display: flex;
padding: 5px 12px 5px 10px; align-items: center;
padding: ${theme.spacing(0.5, 2)};
min-height: ${theme.spacing(4)};
margin: 0; margin: 0;
border: none; border: none;
width: 100%; width: 100%;
@ -177,12 +182,31 @@ const getStyles = (theme: GrafanaTheme2) => {
${getFocusStyles(theme)} ${getFocusStyles(theme)}
} }
`, `,
activeItem: css` active: css`
background: ${theme.colors.action.selected}; background: ${theme.colors.action.hover};
`,
destructive: css`
color: ${theme.colors.error.text};
svg {
color: ${theme.colors.error.text};
}
&:hover,
&:focus,
&:focus-visible {
background: ${theme.colors.error.main};
color: ${theme.colors.error.contrastText};
svg {
color: ${theme.colors.error.contrastText};
}
}
`, `,
icon: css` icon: css`
opacity: 0.7; opacity: 0.7;
margin-right: 10px; margin-right: 10px;
margin-left: -4px;
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
`, `,
}; };

View File

@ -58,6 +58,7 @@ export const SubMenu: React.FC<SubMenuProps> = React.memo(
); );
} }
); );
SubMenu.displayName = 'SubMenu'; SubMenu.displayName = 'SubMenu';
/** @internal */ /** @internal */
@ -70,7 +71,7 @@ const getStyles = (theme: GrafanaTheme2) => {
`, `,
icon: css` icon: css`
opacity: 0.7; opacity: 0.7;
margin-left: 10px; margin-left: ${theme.spacing(2)};
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
`, `,
itemsWrapper: css` itemsWrapper: css`