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: {
|
Panels: {
|
||||||
Panel: {
|
Panel: {
|
||||||
title: (title: string) => `Panel header title item ${title}`,
|
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 = () => {
|
export const Basic = () => {
|
||||||
return <ContextMenu x={10} y={11} onClose={() => {}} items={menuItems} />;
|
return <ContextMenu x={10} y={11} onClose={() => {}} itemsGroup={menuItems} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithState = () => {
|
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 { useClickAway } from 'react-use';
|
||||||
import { Portal } from '../Portal/Portal';
|
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 {
|
export interface ContextMenuProps {
|
||||||
/** Starting horizontal position for the menu */
|
/** Starting horizontal position for the menu */
|
||||||
@ -11,12 +14,12 @@ export interface ContextMenuProps {
|
|||||||
/** Callback for closing the menu */
|
/** Callback for closing the menu */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/** List of the menu items to display */
|
/** List of the menu items to display */
|
||||||
items?: MenuItemsGroup[];
|
itemsGroup?: MenuItemsGroup[];
|
||||||
/** A function that returns header element */
|
/** A function that returns header element */
|
||||||
renderHeader?: () => React.ReactNode;
|
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 menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [positionStyles, setPositionStyles] = useState({});
|
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();
|
const header = renderHeader && renderHeader();
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<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>
|
</Portal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
||||||
import { MenuItemsGroup } from '../Menu/Menu';
|
import { MenuItemsGroup } from '../Menu/MenuGroup';
|
||||||
|
|
||||||
interface WithContextMenuProps {
|
interface WithContextMenuProps {
|
||||||
/** Menu item trigger that accepts openMenu prop */
|
/** Menu item trigger that accepts openMenu prop */
|
||||||
@ -30,7 +30,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getC
|
|||||||
onClose={() => setIsMenuOpen(false)}
|
onClose={() => setIsMenuOpen(false)}
|
||||||
x={menuPosition.x}
|
x={menuPosition.x}
|
||||||
y={menuPosition.y}
|
y={menuPosition.y}
|
||||||
items={getContextMenuItems()}
|
itemsGroup={getContextMenuItems()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -5,7 +5,8 @@ import { ToolbarButtonVariant, ToolbarButton } from '../Button';
|
|||||||
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||||
import { css } from 'emotion';
|
import { css } from 'emotion';
|
||||||
import { useStyles } from '../../themes/ThemeContext';
|
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> {
|
export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -41,14 +42,6 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
|
|||||||
setIsOpen(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@ -64,7 +57,17 @@ export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={styles.menuWrapper}>
|
<div className={styles.menuWrapper}>
|
||||||
<ClickOutsideWrapper onClick={onCloseMenu} parent={document}>
|
<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>
|
</ClickOutsideWrapper>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -29,7 +29,7 @@ export type GraphContextMenuProps = ContextMenuProps & {
|
|||||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||||
getContextMenuSource,
|
getContextMenuSource,
|
||||||
timeZone,
|
timeZone,
|
||||||
items,
|
itemsGroup,
|
||||||
dimensions,
|
dimensions,
|
||||||
contextDimensions,
|
contextDimensions,
|
||||||
...otherProps
|
...otherProps
|
||||||
@ -37,8 +37,8 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
|||||||
const source = getContextMenuSource();
|
const source = getContextMenuSource();
|
||||||
|
|
||||||
// Do not render items that do not have label specified
|
// Do not render items that do not have label specified
|
||||||
const itemsToRender = items
|
const itemsToRender = itemsGroup
|
||||||
? items.map((group) => ({
|
? itemsGroup.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item) => item.label),
|
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 */
|
/** @internal */
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Story } from '@storybook/react';
|
import { Story } from '@storybook/react';
|
||||||
import { Menu, MenuProps } from './Menu';
|
import { Menu, MenuProps } from './Menu';
|
||||||
|
import { MenuItem } from './MenuItem';
|
||||||
|
import { MenuGroup } from './MenuGroup';
|
||||||
import { GraphContextMenuHeader } from '..';
|
import { GraphContextMenuHeader } from '..';
|
||||||
|
import { StoryExample } from '../../utils/storybook/StoryExample';
|
||||||
|
import { VerticalGroup } from '../Layout/Layout';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'General/Menu',
|
title: 'General/Menu',
|
||||||
component: Menu,
|
component: Menu,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
items: { control: { disable: true } },
|
items: { control: { disable: true } },
|
||||||
header: { control: { disable: true } },
|
icon: { control: { type: 'select' } },
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
knobs: {
|
knobs: {
|
||||||
@ -23,39 +27,34 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Simple: Story<MenuProps> = (args) => (
|
export const Simple: Story<MenuProps> = (args) => {
|
||||||
<div>
|
return (
|
||||||
<Menu {...args} />
|
<VerticalGroup>
|
||||||
</div>
|
<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 = {
|
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: (
|
header: (
|
||||||
<GraphContextMenuHeader
|
<GraphContextMenuHeader
|
||||||
timestamp="2020-11-25 19:04:25"
|
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 React from 'react';
|
||||||
import { css, cx } from 'emotion';
|
import { css } from 'emotion';
|
||||||
import { GrafanaTheme, LinkTarget } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { List } from '../List/List';
|
import { useStyles } from '../../themes';
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
/** @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;
|
||||||
/** Array of menu items */
|
children: React.ReactNode;
|
||||||
items?: MenuItemsGroup[];
|
ariaLabel?: string;
|
||||||
/** Callback performed when menu is closed */
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(({ header, items, onClose, ...otherProps }, ref) => {
|
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
|
||||||
const styles = useStyles(getMenuStyles);
|
({ header, children, ariaLabel, ...otherProps }, ref) => {
|
||||||
const onClick = useCallback(() => {
|
const styles = useStyles(getStyles);
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...otherProps} ref={ref} className={styles.wrapper}>
|
<div {...otherProps} ref={ref} className={styles.wrapper} aria-label={ariaLabel}>
|
||||||
{header && <div className={styles.header}>{header}</div>}
|
{header && <div className={styles.header}>{header}</div>}
|
||||||
<List
|
{children}
|
||||||
items={items || []}
|
|
||||||
renderItem={(item) => {
|
|
||||||
return <MenuGroup group={item} onClick={onClick} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
Menu.displayName = 'Menu';
|
Menu.displayName = 'Menu';
|
||||||
|
|
||||||
interface MenuGroupProps {
|
/** @internal */
|
||||||
group: MenuItemsGroup;
|
const getStyles = (theme: GrafanaTheme) => {
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
MenuItemComponent.displayName = 'MenuItemComponent';
|
|
||||||
|
|
||||||
const getMenuStyles = (theme: GrafanaTheme) => {
|
|
||||||
const linkColor = theme.colors.text;
|
|
||||||
const linkColorHover = theme.colors.linkHover;
|
|
||||||
const wrapperBg = theme.colors.formInputBg;
|
const wrapperBg = theme.colors.formInputBg;
|
||||||
const wrapperShadow = theme.isDark ? theme.palette.black : theme.palette.gray3;
|
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 headerBg = theme.colors.formInputBg;
|
||||||
const headerSeparator = theme.colors.border3;
|
const headerSeparator = theme.colors.border3;
|
||||||
|
|
||||||
@ -178,42 +47,5 @@ const getMenuStyles = (theme: GrafanaTheme) => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: ${theme.border.radius.sm};
|
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 = (
|
MenuComponent = (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
|
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
|
||||||
items={items}
|
itemsGroup={items}
|
||||||
onClose={() => setOpenedNode(undefined)}
|
onClose={() => setOpenedNode(undefined)}
|
||||||
x={openedNode.event.pageX}
|
x={openedNode.event.pageX}
|
||||||
y={openedNode.event.pageY}
|
y={openedNode.event.pageY}
|
||||||
@ -49,7 +49,7 @@ export function useContextMenu(
|
|||||||
MenuComponent = (
|
MenuComponent = (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
|
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
|
||||||
items={items}
|
itemsGroup={items}
|
||||||
onClose={() => setOpenedEdge(undefined)}
|
onClose={() => setOpenedEdge(undefined)}
|
||||||
x={openedEdge.event.pageX}
|
x={openedEdge.event.pageX}
|
||||||
y={openedEdge.event.pageY}
|
y={openedEdge.event.pageY}
|
||||||
@ -82,8 +82,10 @@ function getItems(links: LinkModel[]) {
|
|||||||
return Object.keys(groups).map((key) => {
|
return Object.keys(groups).map((key) => {
|
||||||
return {
|
return {
|
||||||
label: key,
|
label: key,
|
||||||
|
ariaLabel: key,
|
||||||
items: groups[key].map((link) => ({
|
items: groups[key].map((link) => ({
|
||||||
label: link.newTitle || link.l.title,
|
label: link.newTitle || link.l.title,
|
||||||
|
ariaLabel: link.newTitle || link.l.title,
|
||||||
url: link.l.href,
|
url: link.l.href,
|
||||||
onClick: link.l.onClick,
|
onClick: link.l.onClick,
|
||||||
})),
|
})),
|
||||||
|
@ -102,7 +102,9 @@ export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
|||||||
export * from './SingleStatShared/index';
|
export * from './SingleStatShared/index';
|
||||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||||
export { ContextMenu, ContextMenuProps } from './ContextMenu/ContextMenu';
|
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 { WithContextMenu } from './ContextMenu/WithContextMenu';
|
||||||
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||||
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { LinkModel } from '@grafana/data';
|
import { LinkModel } from '@grafana/data';
|
||||||
import { MenuItem } from '../components/Menu/Menu';
|
import { MenuItemProps } from '../components/Menu/MenuItem';
|
||||||
import { IconName } from '../types';
|
import { IconName } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delays creating links until we need to open the ContextMenu
|
* 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 links().map((link) => {
|
||||||
return {
|
return {
|
||||||
label: link.title,
|
label: link.title,
|
||||||
|
ariaLabel: link.title,
|
||||||
// TODO: rename to href
|
// TODO: rename to href
|
||||||
url: link.href,
|
url: link.href,
|
||||||
target: link.target,
|
target: link.target,
|
||||||
|
@ -141,7 +141,7 @@ export function registerAngularDirectives() {
|
|||||||
react2AngularDirective('graphContextMenu', GraphContextMenu, [
|
react2AngularDirective('graphContextMenu', GraphContextMenu, [
|
||||||
'x',
|
'x',
|
||||||
'y',
|
'y',
|
||||||
'items',
|
'itemsGroup',
|
||||||
['onClose', { watchDepth: 'reference', wrapApply: true }],
|
['onClose', { watchDepth: 'reference', wrapApply: true }],
|
||||||
['getContextMenuSource', { watchDepth: 'reference', wrapApply: true }],
|
['getContextMenuSource', { watchDepth: 'reference', wrapApply: true }],
|
||||||
['timeZone', { 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';
|
import { FlotDataPoint } from '@grafana/data';
|
||||||
|
|
||||||
export class GraphContextMenuCtrl {
|
export class GraphContextMenuCtrl {
|
||||||
private source?: FlotDataPoint | null;
|
private source?: FlotDataPoint | null;
|
||||||
private scope?: any;
|
private scope?: any;
|
||||||
menuItemsSupplier?: () => MenuItem[];
|
menuItemsSupplier?: () => MenuItemProps[];
|
||||||
scrollContextElement: HTMLElement | null;
|
scrollContextElement: HTMLElement | null;
|
||||||
position: {
|
position: {
|
||||||
x: number;
|
x: number;
|
||||||
@ -61,7 +61,7 @@ export class GraphContextMenuCtrl {
|
|||||||
return this.source;
|
return this.source;
|
||||||
};
|
};
|
||||||
|
|
||||||
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItem[]) => {
|
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItemProps[]) => {
|
||||||
this.menuItemsSupplier = menuItemsSupplier;
|
this.menuItemsSupplier = menuItemsSupplier;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { GraphLegendProps, Legend } from './Legend/Legend';
|
import { GraphLegendProps, Legend } from './Legend/Legend';
|
||||||
|
|
||||||
import { GraphCtrl } from './module';
|
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 { provideTheme } from 'app/core/utils/ConfigProvider';
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@ -207,6 +207,7 @@ class GraphElement {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Add annotation',
|
label: 'Add annotation',
|
||||||
|
ariaLabel: 'Add annotation',
|
||||||
icon: 'comment-alt',
|
icon: 'comment-alt',
|
||||||
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
|
onClick: () => this.eventManager.updateTime({ from: flotPosition.x, to: null }),
|
||||||
},
|
},
|
||||||
@ -221,9 +222,10 @@ class GraphElement {
|
|||||||
|
|
||||||
const dataLinks = [
|
const dataLinks = [
|
||||||
{
|
{
|
||||||
items: linksSupplier.getLinks(this.panel.replaceVariables).map<MenuItem>((link) => {
|
items: linksSupplier.getLinks(this.panel.replaceVariables).map<MenuItemProps>((link) => {
|
||||||
return {
|
return {
|
||||||
label: link.title,
|
label: link.title,
|
||||||
|
ariaLabel: link.title,
|
||||||
url: link.href,
|
url: link.href,
|
||||||
target: link.target,
|
target: link.target,
|
||||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||||
|
@ -8,7 +8,7 @@ const template = `
|
|||||||
</div>
|
</div>
|
||||||
<div ng-if="ctrl.contextMenuCtrl.isVisible">
|
<div ng-if="ctrl.contextMenuCtrl.isVisible">
|
||||||
<graph-context-menu
|
<graph-context-menu
|
||||||
items="ctrl.contextMenuCtrl.menuItemsSupplier()"
|
itemsGroup="ctrl.contextMenuCtrl.menuItemsSupplier()"
|
||||||
onClose="ctrl.onContextMenuClose"
|
onClose="ctrl.onContextMenuClose"
|
||||||
getContextMenuSource="ctrl.contextMenuCtrl.getSource"
|
getContextMenuSource="ctrl.contextMenuCtrl.getSource"
|
||||||
timeZone="ctrl.getTimeZone()"
|
timeZone="ctrl.getTimeZone()"
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
ContextMenu,
|
ContextMenu,
|
||||||
GraphContextMenuHeader,
|
GraphContextMenuHeader,
|
||||||
IconName,
|
IconName,
|
||||||
MenuItem,
|
MenuItemProps,
|
||||||
MenuItemsGroup,
|
MenuItemsGroup,
|
||||||
Portal,
|
Portal,
|
||||||
useGraphNGContext,
|
useGraphNGContext,
|
||||||
@ -135,9 +135,10 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
|||||||
|
|
||||||
if (linksSupplier) {
|
if (linksSupplier) {
|
||||||
items.push({
|
items.push({
|
||||||
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
|
items: linksSupplier.getLinks(replaceVariables).map<MenuItemProps>((link) => {
|
||||||
return {
|
return {
|
||||||
label: link.title,
|
label: link.title,
|
||||||
|
ariaLabel: link.title,
|
||||||
url: link.href,
|
url: link.href,
|
||||||
target: link.target,
|
target: link.target,
|
||||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||||
@ -161,7 +162,7 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
items={items}
|
itemsGroup={items}
|
||||||
renderHeader={renderHeader}
|
renderHeader={renderHeader}
|
||||||
x={selection.coords.viewport.x}
|
x={selection.coords.viewport.x}
|
||||||
y={selection.coords.viewport.y}
|
y={selection.coords.viewport.y}
|
||||||
|
Loading…
Reference in New Issue
Block a user