Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Marbach
b86c42d82a DataLinks: Keyboard event support for context menu 2026-01-06 14:55:18 -05:00
3 changed files with 89 additions and 15 deletions

View File

@@ -3,9 +3,11 @@ import * as React from 'react';
import { ContextMenu } from '../ContextMenu/ContextMenu';
export type OpenMenuFunction = <E extends HTMLElement>(e: React.KeyboardEvent<E> | React.MouseEvent<E>) => void;
export interface WithContextMenuProps {
/** Menu item trigger that accepts openMenu prop */
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
children: (props: { openMenu: OpenMenuFunction }) => JSX.Element;
/** A function that returns an array of menu items */
renderMenuItems: () => React.ReactNode;
/** On menu open focus the first element */
@@ -19,11 +21,19 @@ export const WithContextMenu = ({ children, renderMenuItems, focusOnOpen = true
<>
{children({
openMenu: (e) => {
let x = 0;
let y = 0;
if ('pageX' in e) {
x = e.pageX;
y = e.pageY - window.scrollY;
} else if ('currentTarget' in e) {
const target = e.currentTarget;
const rect = target.getBoundingClientRect();
x = rect.left + rect.width / 2 + window.scrollX;
y = rect.top + rect.height / 2 + window.scrollY;
}
setIsMenuOpen(true);
setMenuPosition({
x: e.pageX,
y: e.pageY - window.scrollY,
});
setMenuPosition({ x, y });
},
})}

View File

@@ -1,10 +1,12 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { DataLinksContextMenu } from './DataLinksContextMenu';
const fakeAriaLabel = 'fake aria label';
describe('DataLinksContextMenu', () => {
it('renders context menu when there are more than one data links', () => {
render(
@@ -24,9 +26,7 @@ describe('DataLinksContextMenu', () => {
},
]}
>
{() => {
return <div aria-label="fake aria label" />;
}}
{() => <div aria-label={fakeAriaLabel} />}
</DataLinksContextMenu>
);
@@ -46,13 +46,76 @@ describe('DataLinksContextMenu', () => {
},
]}
>
{() => {
return <div aria-label="fake aria label" />;
}}
{() => <div aria-label={fakeAriaLabel} />}
</DataLinksContextMenu>
);
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.DataLinksContextMenu.singleLink)).toBeInTheDocument();
});
it('does not render anything when there are no data links', () => {
render(<DataLinksContextMenu links={() => []}>{() => <div aria-label={fakeAriaLabel} />}</DataLinksContextMenu>);
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.DataLinksContextMenu.singleLink)).not.toBeInTheDocument();
expect(screen.queryByLabelText(selectors.components.Menu.MenuComponent('Context'))).not.toBeInTheDocument();
});
describe('openMenu function', () => {
it('accepts mouse event', async () => {
render(
<DataLinksContextMenu
links={() => [
{
href: '/link1',
title: 'Link1',
target: '_blank',
origin: {},
},
{
href: '/link2',
title: 'Link2',
target: '_blank',
origin: {},
},
]}
>
{({ openMenu }) => <div aria-label={fakeAriaLabel} onClick={openMenu} />}
</DataLinksContextMenu>
);
await userEvent.click(screen.getByLabelText(fakeAriaLabel));
expect(screen.getByLabelText(selectors.components.Menu.MenuComponent('Context'))).toBeInTheDocument();
});
it('accepts keyboard event', async () => {
render(
<DataLinksContextMenu
links={() => [
{
href: '/link1',
title: 'Link1',
target: '_blank',
origin: {},
},
{
href: '/link2',
title: 'Link2',
target: '_blank',
origin: {},
},
]}
>
{({ openMenu }) => <div tabIndex={0} aria-label={fakeAriaLabel} onKeyDown={openMenu} />}
</DataLinksContextMenu>
);
await userEvent.click(screen.getByLabelText(fakeAriaLabel));
await userEvent.keyboard('Enter');
expect(screen.getByLabelText(selectors.components.Menu.MenuComponent('Context'))).toBeInTheDocument();
});
});
});

View File

@@ -1,13 +1,12 @@
import { css } from '@emotion/css';
import { CSSProperties, type JSX } from 'react';
import * as React from 'react';
import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../themes/ThemeContext';
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
import { WithContextMenu, OpenMenuFunction } from '../ContextMenu/WithContextMenu';
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
@@ -22,7 +21,7 @@ export interface DataLinksContextMenuProps {
}
export interface DataLinksContextMenuApi {
openMenu?: React.MouseEventHandler<HTMLOrSVGElement>;
openMenu?: OpenMenuFunction;
targetClassName?: string;
}
@@ -66,7 +65,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
}}
</WithContextMenu>
);
} else {
} else if (linksCounter === 1) {
const linkModel = links()[0];
return (
<a
@@ -80,6 +79,8 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
{children({})}
</a>
);
} else {
return <>{children({})}</>;
}
};