mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Menu: refactor MenuItem and MenuGroup to be standalone component (#31639)
* Menu: refactor MenuItem and MenuGroup to be standalone component * fixes small nits * Chore: Refactored other components to correspond with the new Menu system (#31676) * fixes affected components using Menu * fixes affected components using Menu components * fixes frontend test- I hope * fixes frontend docs test- I hope * fixes frontend docs test- I hope * fixes frontend docs test- I hope * fixes frontend docs test- I hope * fixes frontend docs test- I hope * added support for accessibility * fixes frontend test- I hope * Improve storybook story and simplify ButtonSelect * Fixed broken graph context menu * fixes frontend test- I hope Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
77a024abb3
commit
f51653647d
@ -13,6 +13,11 @@ export const Components = {
|
||||
},
|
||||
},
|
||||
},
|
||||
Menu: {
|
||||
MenuComponent: (title: string) => `${title} menu`,
|
||||
MenuGroup: (title: string) => `${title} menu group`,
|
||||
MenuItem: (title: string) => `${title} menu item`,
|
||||
},
|
||||
Panels: {
|
||||
Panel: {
|
||||
title: (title: string) => `Panel header title item ${title}`,
|
||||
|
@ -16,10 +16,18 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
const menuItems = [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }];
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Test',
|
||||
items: [
|
||||
{ label: 'First', ariaLabel: 'First' },
|
||||
{ label: 'Second', ariaLabel: 'Second' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const Basic = () => {
|
||||
return <ContextMenu x={10} y={11} onClose={() => {}} items={menuItems} />;
|
||||
return <ContextMenu x={10} y={11} onClose={() => {}} itemsGroup={menuItems} />;
|
||||
};
|
||||
|
||||
export const WithState = () => {
|
||||
|
@ -1,7 +1,10 @@
|
||||
import React, { useRef, useState, useLayoutEffect } from 'react';
|
||||
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { useClickAway } from 'react-use';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { Menu, MenuItemsGroup } from '../Menu/Menu';
|
||||
import { Menu } from '../Menu/Menu';
|
||||
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
|
||||
import { MenuItem } from '../Menu/MenuItem';
|
||||
|
||||
export interface ContextMenuProps {
|
||||
/** Starting horizontal position for the menu */
|
||||
@ -11,12 +14,12 @@ export interface ContextMenuProps {
|
||||
/** Callback for closing the menu */
|
||||
onClose?: () => void;
|
||||
/** List of the menu items to display */
|
||||
items?: MenuItemsGroup[];
|
||||
itemsGroup?: MenuItemsGroup[];
|
||||
/** A function that returns header element */
|
||||
renderHeader?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, items, renderHeader }) => {
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, itemsGroup, renderHeader }) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [positionStyles, setPositionStyles] = useState({});
|
||||
|
||||
@ -44,10 +47,38 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClo
|
||||
}
|
||||
});
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
const header = renderHeader && renderHeader();
|
||||
return (
|
||||
<Portal>
|
||||
<Menu header={header} items={items} onClose={onClose} ref={menuRef} style={positionStyles} />
|
||||
<Menu
|
||||
header={header}
|
||||
ref={menuRef}
|
||||
style={positionStyles}
|
||||
ariaLabel={selectors.components.Menu.MenuComponent('Context')}
|
||||
>
|
||||
{itemsGroup?.map((group, index) => (
|
||||
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
|
||||
{(group.items || []).map((item) => (
|
||||
<MenuItem
|
||||
key={`${item.label}`}
|
||||
url={item.url}
|
||||
label={item.label}
|
||||
ariaLabel={item.label}
|
||||
target={item.target}
|
||||
icon={item.icon}
|
||||
active={item.active}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</MenuGroup>
|
||||
))}
|
||||
</Menu>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
||||
import { MenuItemsGroup } from '../Menu/Menu';
|
||||
import { MenuItemsGroup } from '../Menu/MenuGroup';
|
||||
|
||||
interface WithContextMenuProps {
|
||||
/** Menu item trigger that accepts openMenu prop */
|
||||
@ -30,7 +30,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getC
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
x={menuPosition.x}
|
||||
y={menuPosition.y}
|
||||
items={getContextMenuItems()}
|
||||
itemsGroup={getContextMenuItems()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -5,7 +5,8 @@ import { ToolbarButtonVariant, ToolbarButton } from '../Button';
|
||||
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
import { css } from 'emotion';
|
||||
import { useStyles } from '../../themes/ThemeContext';
|
||||
import { Menu, MenuItemsGroup } from '../Menu/Menu';
|
||||
import { Menu } from '../Menu/Menu';
|
||||
import { MenuItem } from '../Menu/MenuItem';
|
||||
|
||||
export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
|
||||
className?: string;
|
||||
@ -41,14 +42,6 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const menuGroup: MenuItemsGroup = {
|
||||
items: options.map((item) => ({
|
||||
label: (item.label || item.value) as string,
|
||||
onClick: () => onChangeInternal(item),
|
||||
active: item.value === value?.value,
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
@ -64,7 +57,17 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
|
||||
{isOpen && (
|
||||
<div className={styles.menuWrapper}>
|
||||
<ClickOutsideWrapper onClick={onCloseMenu} parent={document}>
|
||||
<Menu items={[menuGroup]} />
|
||||
<Menu>
|
||||
{options.map((item) => (
|
||||
<MenuItem
|
||||
key={`${item.value}`}
|
||||
label={(item.label || item.value) as string}
|
||||
ariaLabel={(item.label || item.value) as string}
|
||||
onClick={() => onChangeInternal(item)}
|
||||
active={item.value === value?.value}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</ClickOutsideWrapper>
|
||||
</div>
|
||||
)}
|
||||
|
@ -29,7 +29,7 @@ export type GraphContextMenuProps = ContextMenuProps & {
|
||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
getContextMenuSource,
|
||||
timeZone,
|
||||
items,
|
||||
itemsGroup,
|
||||
dimensions,
|
||||
contextDimensions,
|
||||
...otherProps
|
||||
@ -37,8 +37,8 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
const source = getContextMenuSource();
|
||||
|
||||
// Do not render items that do not have label specified
|
||||
const itemsToRender = items
|
||||
? items.map((group) => ({
|
||||
const itemsToRender = itemsGroup
|
||||
? itemsGroup.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => item.label),
|
||||
}))
|
||||
@ -81,7 +81,7 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
|
||||
return <ContextMenu {...otherProps} itemsGroup={itemsToRender} renderHeader={renderHeader} />;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
|
@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { Menu, MenuProps } from './Menu';
|
||||
import { MenuItem } from './MenuItem';
|
||||
import { MenuGroup } from './MenuGroup';
|
||||
import { GraphContextMenuHeader } from '..';
|
||||
import { StoryExample } from '../../utils/storybook/StoryExample';
|
||||
import { VerticalGroup } from '../Layout/Layout';
|
||||
|
||||
export default {
|
||||
title: 'General/Menu',
|
||||
component: Menu,
|
||||
argTypes: {
|
||||
items: { control: { disable: true } },
|
||||
header: { control: { disable: true } },
|
||||
icon: { control: { type: 'select' } },
|
||||
},
|
||||
parameters: {
|
||||
knobs: {
|
||||
@ -23,39 +27,34 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
export const Simple: Story<MenuProps> = (args) => (
|
||||
<div>
|
||||
<Menu {...args} />
|
||||
</div>
|
||||
);
|
||||
export const Simple: Story<MenuProps> = (args) => {
|
||||
return (
|
||||
<VerticalGroup>
|
||||
<StoryExample name="Simple">
|
||||
<Menu>
|
||||
<MenuItem label="Google" icon="search-plus" ariaLabel="Menu item" />
|
||||
<MenuItem label="Filter" icon="filter" ariaLabel="Menu item" />
|
||||
<MenuItem label="History" icon="history" ariaLabel="Menu item" />
|
||||
<MenuItem label="Active" icon="history" active ariaLabel="Menu item" />
|
||||
<MenuItem label="Apps" icon="apps" ariaLabel="Menu item" />
|
||||
</Menu>
|
||||
</StoryExample>
|
||||
<StoryExample name="With header & groups">
|
||||
<Menu header={args.header} ariaLabel="Menu header">
|
||||
<MenuGroup label="Group 1" ariaLabel="Menu Group">
|
||||
<MenuItem label="item1" icon="history" ariaLabel="Menu item" />
|
||||
<MenuItem label="item2" icon="filter" ariaLabel="Menu item" />
|
||||
</MenuGroup>
|
||||
<MenuGroup label="Group 2" ariaLabel="Menu Group">
|
||||
<MenuItem label="item1" icon="history" ariaLabel="Menu item" />
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
</StoryExample>
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
Simple.args = {
|
||||
items: [
|
||||
{
|
||||
label: 'Group 1',
|
||||
items: [
|
||||
{
|
||||
label: 'Menu item 1',
|
||||
icon: 'history',
|
||||
},
|
||||
{
|
||||
label: 'Menu item 2',
|
||||
icon: 'filter',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
items: [
|
||||
{
|
||||
label: 'Menu item 1',
|
||||
},
|
||||
{
|
||||
label: 'Menu item 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
header: (
|
||||
<GraphContextMenuHeader
|
||||
timestamp="2020-11-25 19:04:25"
|
||||
|
45
packages/grafana-ui/src/components/Menu/Menu.test.tsx
Normal file
45
packages/grafana-ui/src/components/Menu/Menu.test.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Menu } from './Menu';
|
||||
import { MenuGroup } from './MenuGroup';
|
||||
import { MenuItem } from './MenuItem';
|
||||
|
||||
describe('Menu', () => {
|
||||
it('renders items without error', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<Menu ariaLabel={selectors.components.Menu.MenuComponent('Test')} header="mock header">
|
||||
<MenuGroup ariaLabel={selectors.components.Menu.MenuGroup('Test')} label="Group 1">
|
||||
<MenuItem
|
||||
ariaLabel={selectors.components.Menu.MenuItem('Test')}
|
||||
label="item1"
|
||||
icon="history"
|
||||
active={true}
|
||||
/>
|
||||
<MenuItem
|
||||
ariaLabel={selectors.components.Menu.MenuItem('Test')}
|
||||
label="item2"
|
||||
icon="filter"
|
||||
active={true}
|
||||
/>
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders correct contents', () => {
|
||||
render(
|
||||
<Menu ariaLabel={selectors.components.Menu.MenuComponent('Test')} header="mock header">
|
||||
<MenuGroup ariaLabel={selectors.components.Menu.MenuGroup('Test')} label="Group 1">
|
||||
<MenuItem ariaLabel={selectors.components.Menu.MenuItem('Test')} label="item1" icon="history" active={true} />
|
||||
<MenuItem ariaLabel={selectors.components.Menu.MenuItem('Test')} label="item2" icon="filter" active={true} />
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
);
|
||||
expect(screen.getByLabelText(selectors.components.Menu.MenuComponent('Test'))).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(selectors.components.Menu.MenuGroup('Test'))).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText(selectors.components.Menu.MenuItem('Test'))).toHaveLength(2);
|
||||
});
|
||||
});
|
@ -1,166 +1,35 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme, LinkTarget } from '@grafana/data';
|
||||
import { List } from '../List/List';
|
||||
import { styleMixins, useStyles } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../../types';
|
||||
|
||||
/** @internal */
|
||||
export interface MenuItem {
|
||||
/** Label of the menu item */
|
||||
label: string;
|
||||
/** Target of the menu item (i.e. new window) */
|
||||
target?: LinkTarget;
|
||||
/** Icon of the menu item */
|
||||
icon?: IconName;
|
||||
/** Url of the menu item */
|
||||
url?: string;
|
||||
/** Handler for the click behaviour */
|
||||
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
|
||||
/** Handler for the click behaviour */
|
||||
group?: string;
|
||||
/** Active */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface MenuItemsGroup {
|
||||
/** Label for the menu items group */
|
||||
label?: string;
|
||||
/** Items of the group */
|
||||
items: MenuItem[];
|
||||
}
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '../../themes';
|
||||
|
||||
/** @internal */
|
||||
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** React element rendered at the top of the menu */
|
||||
header?: React.ReactNode;
|
||||
/** Array of menu items */
|
||||
items?: MenuItemsGroup[];
|
||||
/** Callback performed when menu is closed */
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(({ header, items, onClose, ...otherProps }, ref) => {
|
||||
const styles = useStyles(getMenuStyles);
|
||||
const onClick = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div {...otherProps} ref={ref} className={styles.wrapper}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<List
|
||||
items={items || []}
|
||||
renderItem={(item) => {
|
||||
return <MenuGroup group={item} onClick={onClick} />;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Menu.displayName = 'Menu';
|
||||
|
||||
interface MenuGroupProps {
|
||||
group: MenuItemsGroup;
|
||||
onClick?: () => void; // Used with 'onClose'
|
||||
}
|
||||
|
||||
const MenuGroup: React.FC<MenuGroupProps> = ({ group, onClick }) => {
|
||||
const styles = useStyles(getMenuStyles);
|
||||
|
||||
if (group.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{group.label && <div className={styles.groupLabel}>{group.label}</div>}
|
||||
<List
|
||||
items={group.items || []}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<MenuItemComponent
|
||||
url={item.url}
|
||||
label={item.label}
|
||||
target={item.target}
|
||||
icon={item.icon}
|
||||
active={item.active}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
// We can have both url and onClick and we want to allow user to open the link in new tab/window
|
||||
const isSpecialKeyPressed = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (isSpecialKeyPressed && item.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
e.preventDefault();
|
||||
item.onClick(e);
|
||||
}
|
||||
|
||||
// Typically closes the context menu
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MenuGroup.displayName = 'MenuGroup';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
url?: string;
|
||||
target?: LinkTarget;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const MenuItemComponent: React.FC<MenuItemProps> = React.memo(
|
||||
({ url, icon, label, target, onClick, className, active }) => {
|
||||
const styles = useStyles(getMenuStyles);
|
||||
const itemStyle = cx(
|
||||
{
|
||||
[styles.item]: true,
|
||||
[styles.activeItem]: active,
|
||||
},
|
||||
className
|
||||
);
|
||||
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
|
||||
({ header, children, ariaLabel, ...otherProps }, ref) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<div className={itemStyle}>
|
||||
<a
|
||||
href={url ? url : undefined}
|
||||
target={target}
|
||||
className={styles.link}
|
||||
onClick={onClick}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{icon && <Icon name={icon} className={styles.icon} />} {label}
|
||||
</a>
|
||||
<div {...otherProps} ref={ref} className={styles.wrapper} aria-label={ariaLabel}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuItemComponent.displayName = 'MenuItemComponent';
|
||||
Menu.displayName = 'Menu';
|
||||
|
||||
const getMenuStyles = (theme: GrafanaTheme) => {
|
||||
const linkColor = theme.colors.text;
|
||||
const linkColorHover = theme.colors.linkHover;
|
||||
/** @internal */
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const wrapperBg = theme.colors.formInputBg;
|
||||
const wrapperShadow = theme.isDark ? theme.palette.black : theme.palette.gray3;
|
||||
const groupLabelColor = theme.colors.textWeak;
|
||||
const itemBgHover = styleMixins.hoverColor(theme.colors.bg1, theme);
|
||||
const headerBg = theme.colors.formInputBg;
|
||||
const headerSeparator = theme.colors.border3;
|
||||
|
||||
@ -178,42 +47,5 @@ const getMenuStyles = (theme: GrafanaTheme) => {
|
||||
display: inline-block;
|
||||
border-radius: ${theme.border.radius.sm};
|
||||
`,
|
||||
link: css`
|
||||
color: ${linkColor};
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: 5px 12px 5px 10px;
|
||||
|
||||
&:hover {
|
||||
color: ${linkColorHover};
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
item: css`
|
||||
background: none;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: ${itemBgHover};
|
||||
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
||||
border-image-slice: 1;
|
||||
}
|
||||
`,
|
||||
activeItem: css`
|
||||
background: ${theme.colors.bg2};
|
||||
`,
|
||||
groupLabel: css`
|
||||
color: ${groupLabelColor};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
line-height: ${theme.typography.lineHeight.md};
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
`,
|
||||
icon: css`
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
50
packages/grafana-ui/src/components/Menu/MenuGroup.tsx
Normal file
50
packages/grafana-ui/src/components/Menu/MenuGroup.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '../../themes';
|
||||
import { MenuItemProps } from './MenuItem';
|
||||
|
||||
/** @internal */
|
||||
export interface MenuItemsGroup {
|
||||
/** Label for the menu items group */
|
||||
label?: string;
|
||||
/** Aria label for accessibility support */
|
||||
ariaLabel?: string;
|
||||
/** Items of the group */
|
||||
items: MenuItemProps[];
|
||||
}
|
||||
/** @internal */
|
||||
export interface MenuGroupProps extends Partial<MenuItemsGroup> {
|
||||
/** special children prop to pass children elements */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const MenuGroup: React.FC<MenuGroupProps> = ({ label, children, ariaLabel }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<div className={styles.groupLabel} aria-label={ariaLabel}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MenuGroup.displayName = 'MenuGroup';
|
||||
|
||||
/** @internal */
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const groupLabelColor = theme.colors.textWeak;
|
||||
return {
|
||||
groupLabel: css`
|
||||
color: ${groupLabelColor};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
line-height: ${theme.typography.lineHeight.md};
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
`,
|
||||
};
|
||||
};
|
94
packages/grafana-ui/src/components/Menu/MenuItem.tsx
Normal file
94
packages/grafana-ui/src/components/Menu/MenuItem.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme, LinkTarget } from '@grafana/data';
|
||||
import { styleMixins, useStyles } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../../types';
|
||||
|
||||
/** @internal */
|
||||
export interface MenuItemProps {
|
||||
/** Label of the menu item */
|
||||
label: string;
|
||||
/** Aria label for accessibility support */
|
||||
ariaLabel: string;
|
||||
/** Target of the menu item (i.e. new window) */
|
||||
target?: LinkTarget;
|
||||
/** Icon of the menu item */
|
||||
icon?: IconName;
|
||||
/** Url of the menu item */
|
||||
url?: string;
|
||||
/** Handler for the click behaviour */
|
||||
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
|
||||
/** Custom MenuItem styles*/
|
||||
className?: string;
|
||||
/** Active */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const MenuItem: React.FC<MenuItemProps> = React.memo(
|
||||
({ url, icon, label, ariaLabel, target, onClick, className, active }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
const itemStyle = cx(
|
||||
{
|
||||
[styles.item]: true,
|
||||
[styles.activeItem]: active,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={itemStyle} aria-label={ariaLabel}>
|
||||
<a
|
||||
href={url ? url : undefined}
|
||||
target={target}
|
||||
className={styles.link}
|
||||
onClick={onClick}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{icon && <Icon name={icon} className={styles.icon} />} {label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
|
||||
/** @internal */
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const linkColor = theme.colors.text;
|
||||
const linkColorHover = theme.colors.linkHover;
|
||||
const itemBgHover = styleMixins.hoverColor(theme.colors.bg1, theme);
|
||||
|
||||
return {
|
||||
link: css`
|
||||
color: ${linkColor};
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: 5px 12px 5px 10px;
|
||||
&:hover {
|
||||
color: ${linkColorHover};
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
item: css`
|
||||
background: none;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
background: ${itemBgHover};
|
||||
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
||||
border-image-slice: 1;
|
||||
}
|
||||
`,
|
||||
activeItem: css`
|
||||
background: ${theme.colors.bg2};
|
||||
`,
|
||||
icon: css`
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
};
|
||||
};
|
@ -34,7 +34,7 @@ export function useContextMenu(
|
||||
MenuComponent = (
|
||||
<ContextMenu
|
||||
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
|
||||
items={items}
|
||||
itemsGroup={items}
|
||||
onClose={() => setOpenedNode(undefined)}
|
||||
x={openedNode.event.pageX}
|
||||
y={openedNode.event.pageY}
|
||||
@ -49,7 +49,7 @@ export function useContextMenu(
|
||||
MenuComponent = (
|
||||
<ContextMenu
|
||||
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
|
||||
items={items}
|
||||
itemsGroup={items}
|
||||
onClose={() => setOpenedEdge(undefined)}
|
||||
x={openedEdge.event.pageX}
|
||||
y={openedEdge.event.pageY}
|
||||
@ -82,8 +82,10 @@ function getItems(links: LinkModel[]) {
|
||||
return Object.keys(groups).map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
ariaLabel: key,
|
||||
items: groups[key].map((link) => ({
|
||||
label: link.newTitle || link.l.title,
|
||||
ariaLabel: link.newTitle || link.l.title,
|
||||
url: link.l.href,
|
||||
onClick: link.l.onClick,
|
||||
})),
|
||||
|
@ -102,7 +102,9 @@ export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
export * from './SingleStatShared/index';
|
||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||
export { ContextMenu, ContextMenuProps } from './ContextMenu/ContextMenu';
|
||||
export { Menu, MenuItem, MenuItemsGroup } from './Menu/Menu';
|
||||
export { Menu, MenuProps } from './Menu/Menu';
|
||||
export { MenuGroup, MenuItemsGroup, MenuGroupProps } from './Menu/MenuGroup';
|
||||
export { MenuItem, MenuItemProps } from './Menu/MenuItem';
|
||||
export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
||||
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { LinkModel } from '@grafana/data';
|
||||
import { MenuItem } from '../components/Menu/Menu';
|
||||
import { MenuItemProps } from '../components/Menu/MenuItem';
|
||||
import { IconName } from '../types';
|
||||
|
||||
/**
|
||||
* Delays creating links until we need to open the ContextMenu
|
||||
*/
|
||||
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItem[] = (links) => {
|
||||
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItemProps[] = (links) => {
|
||||
return links().map((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
ariaLabel: link.title,
|
||||
// TODO: rename to href
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
|
@ -141,7 +141,7 @@ export function registerAngularDirectives() {
|
||||
react2AngularDirective('graphContextMenu', GraphContextMenu, [
|
||||
'x',
|
||||
'y',
|
||||
'items',
|
||||
'itemsGroup',
|
||||
['onClose', { watchDepth: 'reference', wrapApply: true }],
|
||||
['getContextMenuSource', { watchDepth: 'reference', wrapApply: true }],
|
||||
['timeZone', { watchDepth: 'reference', wrapApply: true }],
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { MenuItem } from '@grafana/ui';
|
||||
import { MenuItemProps } from '@grafana/ui';
|
||||
import { FlotDataPoint } from '@grafana/data';
|
||||
|
||||
export class GraphContextMenuCtrl {
|
||||
private source?: FlotDataPoint | null;
|
||||
private scope?: any;
|
||||
menuItemsSupplier?: () => MenuItem[];
|
||||
menuItemsSupplier?: () => MenuItemProps[];
|
||||
scrollContextElement: HTMLElement | null;
|
||||
position: {
|
||||
x: number;
|
||||
@ -61,7 +61,7 @@ export class GraphContextMenuCtrl {
|
||||
return this.source;
|
||||
};
|
||||
|
||||
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItem[]) => {
|
||||
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItemProps[]) => {
|
||||
this.menuItemsSupplier = menuItemsSupplier;
|
||||
};
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ import ReactDOM from 'react-dom';
|
||||
import { GraphLegendProps, Legend } from './Legend/Legend';
|
||||
|
||||
import { GraphCtrl } from './module';
|
||||
import { graphTickFormatter, graphTimeFormat, IconName, MenuItem, MenuItemsGroup } from '@grafana/ui';
|
||||
import { graphTickFormatter, graphTimeFormat, IconName, MenuItemProps, MenuItemsGroup } from '@grafana/ui';
|
||||
import { provideTheme } from 'app/core/utils/ConfigProvider';
|
||||
import {
|
||||
DataFrame,
|
||||
@ -207,6 +207,7 @@ class GraphElement {
|
||||
items: [
|
||||
{
|
||||
label: 'Add annotation',
|
||||
ariaLabel: 'Add annotation',
|
||||
icon: 'comment-alt',
|
||||
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
|
||||
},
|
||||
@ -221,9 +222,10 @@ class GraphElement {
|
||||
|
||||
const dataLinks = [
|
||||
{
|
||||
items: linksSupplier.getLinks(this.panel.replaceVariables).map<MenuItem>((link) => {
|
||||
items: linksSupplier.getLinks(this.panel.replaceVariables).map<MenuItemProps>((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
ariaLabel: link.title,
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||
|
@ -8,7 +8,7 @@ const template = `
|
||||
</div>
|
||||
<div ng-if="ctrl.contextMenuCtrl.isVisible">
|
||||
<graph-context-menu
|
||||
items="ctrl.contextMenuCtrl.menuItemsSupplier()"
|
||||
itemsGroup="ctrl.contextMenuCtrl.menuItemsSupplier()"
|
||||
onClose="ctrl.onContextMenuClose"
|
||||
getContextMenuSource="ctrl.contextMenuCtrl.getSource"
|
||||
timeZone="ctrl.getTimeZone()"
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
ContextMenu,
|
||||
GraphContextMenuHeader,
|
||||
IconName,
|
||||
MenuItem,
|
||||
MenuItemProps,
|
||||
MenuItemsGroup,
|
||||
Portal,
|
||||
useGraphNGContext,
|
||||
@ -135,9 +135,10 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
||||
|
||||
if (linksSupplier) {
|
||||
items.push({
|
||||
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
|
||||
items: linksSupplier.getLinks(replaceVariables).map<MenuItemProps>((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
ariaLabel: link.title,
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||
@ -161,7 +162,7 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
items={items}
|
||||
itemsGroup={items}
|
||||
renderHeader={renderHeader}
|
||||
x={selection.coords.viewport.x}
|
||||
y={selection.coords.viewport.y}
|
||||
|
Loading…
Reference in New Issue
Block a user