mirror of
https://github.com/grafana/grafana.git
synced 2026-01-15 05:35:41 +00:00
Compare commits
10 Commits
sriram/SQL
...
fastfrwrd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
849098bcf0 | ||
|
|
f70bc80a5c | ||
|
|
254a7b9971 | ||
|
|
838bc756db | ||
|
|
bb012056eb | ||
|
|
4ac1bbc657 | ||
|
|
d46947ccb2 | ||
|
|
45bdeefbdf | ||
|
|
eb3df4fa00 | ||
|
|
65919fc0bf |
99
CONTRIBUTION.md
Normal file
99
CONTRIBUTION.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Contribution: Pie Chart Keyboard Focus Indicator Fix
|
||||
|
||||
## Issue
|
||||
**Issue #114227**: Keyboard focus indicator is not visible on data links in Pie Chart visualization
|
||||
|
||||
When keyboard users navigate through a Pie Chart panel using the Tab key, data links (clickable links on chart segments) do not show a visible focus indicator. This makes it impossible for keyboard-only users to know which element is currently focused, reducing navigation clarity and accessibility.
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Root Cause
|
||||
1. Pie chart slices with data links are rendered as SVG `<g>` elements
|
||||
2. These elements are not naturally keyboard focusable (SVG elements need `tabIndex` to be focusable)
|
||||
3. No focus event handlers were attached to trigger visual feedback
|
||||
4. No keyboard activation support (Enter/Space keys)
|
||||
|
||||
### Two Rendering Cases
|
||||
- **Single link case**: `DataLinksContextMenu` wraps the slice in an `<a>` tag
|
||||
- **Multiple links case**: `DataLinksContextMenu` provides an `openMenu` function via render prop
|
||||
|
||||
## Solution Approach
|
||||
|
||||
### 1. Data Link Detection
|
||||
- Check for data links using `arc.data.hasLinks` and `arc.data.getLinks`
|
||||
- Determine if slice should be focusable based on presence of links
|
||||
|
||||
### 2. Keyboard Accessibility
|
||||
- Added `tabIndex={0}` for slices with data links (multiple links case)
|
||||
- Added `tabIndex={-1}` for slices wrapped in `<a>` tag (single link case) to prevent double focus
|
||||
- Added `role="link"` and `aria-label` for proper screen reader support
|
||||
|
||||
### 3. Focus Indicator
|
||||
- Leveraged Grafana's existing `DataHoverEvent` system for highlighting
|
||||
- On focus, publish `DataHoverEvent` to trigger the same visual highlight as mouse hover
|
||||
- On blur, publish `DataHoverClearEvent` to clear the highlight
|
||||
- Used a small delay on blur to prevent flickering during focus transitions
|
||||
|
||||
### 4. Keyboard Activation
|
||||
- Added `handleKeyDown` to handle Enter key presses
|
||||
- For multiple links: Create synthetic mouse event and call `openMenu` directly
|
||||
- For single link: Dispatch native click event to trigger the `<a>` tag's default behavior
|
||||
|
||||
### 5. Single Link Case Handling
|
||||
- Detected when slice is wrapped in `<a>` tag (when `openMenu` is undefined)
|
||||
- Attached focus/blur event listeners to the parent `<a>` tag
|
||||
- Ensured inner `<g>` element has `tabIndex={-1}` to prevent tab order conflicts
|
||||
- Ensured `<a>` tag is properly focusable (remove any `tabIndex="-1"` if present)
|
||||
|
||||
### 6. Tab Order Optimization
|
||||
- Sorted slices with data links first in the DOM to ensure proper tab order
|
||||
- This ensures keyboard users reach interactive elements before non-interactive ones
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Key Changes in `PieChart.tsx`
|
||||
|
||||
1. **Imports**: Added `useRef` and `useEffect` from React
|
||||
|
||||
2. **SliceProps Interface**: Added `outerRadius` and `innerRadius` props to calculate click coordinates
|
||||
|
||||
3. **PieSlice Component**:
|
||||
- Added `elementRef` and `blurTimeoutRef` for DOM manipulation and blur handling
|
||||
- Detected data links: `hasDataLinksDirect` and `hasDataLinks`
|
||||
- Determined focusability: `shouldBeFocusable` (true only for multiple links case)
|
||||
- Added `useEffect` hook to handle single link case (`<a>` tag focus/blur)
|
||||
- Added `handleFocus` callback to publish `DataHoverEvent` on focus
|
||||
- Added `handleBlur` callback to publish `DataHoverClearEvent` on blur (with delay)
|
||||
- Added `handleKeyDown` callback to handle Enter key activation
|
||||
- Updated `<g>` element with accessibility attributes:
|
||||
- `tabIndex={shouldBeFocusable ? 0 : -1}`
|
||||
- `role={shouldBeFocusable ? 'link' : undefined}`
|
||||
- `aria-label={shouldBeFocusable ? ... : undefined}`
|
||||
- `onKeyDown={shouldBeFocusable ? handleKeyDown : undefined}`
|
||||
- `onFocus={hasDataLinks ? handleFocus : undefined}`
|
||||
- `onBlur={hasDataLinks ? handleBlur : undefined}`
|
||||
- `style={{ outline: 'none' }}` to remove browser default outline
|
||||
|
||||
4. **PieChart Component**:
|
||||
- Added sorting to `pie.arcs.map` to put slices with data links first
|
||||
- Passed `outerRadius` and `innerRadius` to `PieSlice` components
|
||||
|
||||
## Testing
|
||||
|
||||
### Expected Behavior
|
||||
- Tab navigation reaches slices with data links
|
||||
- Focused slice shows visual highlight (scales up, others fade)
|
||||
- Enter key activates the data link menu
|
||||
- Tab order is logical (data link slices appear before non-link slices)
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `public/app/plugins/panel/piechart/PieChart.tsx`
|
||||
|
||||
## References
|
||||
|
||||
- [WCAG 2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html)
|
||||
- [Grafana Contributing Guide](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md)
|
||||
- Issue: https://github.com/grafana/grafana/issues/114227
|
||||
|
||||
|
||||
@@ -17,5 +17,9 @@ test.describe(
|
||||
);
|
||||
await expect(pieChartSlices).toHaveCount(5);
|
||||
});
|
||||
|
||||
describe('keyboard accessibility', () => {
|
||||
// FIXME: DATALINKS ACCESSIBILITY TESTS HERE
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { MenuItem, MenuGroup } from '@grafana/ui';
|
||||
|
||||
import { WithContextMenu } from './WithContextMenu';
|
||||
|
||||
describe('WithContextMenu', () => {
|
||||
it('supports mouse events', async () => {
|
||||
render(
|
||||
<WithContextMenu
|
||||
renderMenuItems={() => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItem label="Item 1" />
|
||||
<MenuItem label="Item 2" />
|
||||
</MenuGroup>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => (
|
||||
<div data-testid="context-menu-target" onClick={openMenu}>
|
||||
Click me
|
||||
</div>
|
||||
)}
|
||||
</WithContextMenu>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('context-menu-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 2')).not.toBeInTheDocument();
|
||||
|
||||
// Simulate click to open context menu
|
||||
await userEvent.click(screen.getByTestId('context-menu-target'));
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FIXME: this test isn't correct yet, probably because of how I've done the user-events wrong
|
||||
it('supports keyboard events', async () => {
|
||||
class DOMRect {
|
||||
public get top(): number {
|
||||
return this.y;
|
||||
}
|
||||
public get left(): number {
|
||||
return this.x;
|
||||
}
|
||||
public get bottom(): number {
|
||||
return this.y + this.height;
|
||||
}
|
||||
public get right(): number {
|
||||
return this.x + this.width;
|
||||
}
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0,
|
||||
public width = 0,
|
||||
public height = 0
|
||||
) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
static fromRect(other: DOMRect) {
|
||||
return new DOMRect(other.x, other.y, other.width, other.height);
|
||||
}
|
||||
toJSON() {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
<WithContextMenu
|
||||
renderMenuItems={() => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItem label="Item 1" />
|
||||
<MenuItem label="Item 2" />
|
||||
</MenuGroup>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => (
|
||||
<div
|
||||
data-testid="context-menu-target"
|
||||
tabIndex={0}
|
||||
onKeyDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
openMenu(ev);
|
||||
}}
|
||||
>
|
||||
Press enter on me
|
||||
</div>
|
||||
)}
|
||||
</WithContextMenu>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('context-menu-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 2')).not.toBeInTheDocument();
|
||||
|
||||
const target = screen.getByTestId('context-menu-target');
|
||||
|
||||
// Simulate key down to open context menu
|
||||
await userEvent.type(target, ' ');
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports explicit positioning', async () => {
|
||||
render(
|
||||
<WithContextMenu
|
||||
renderMenuItems={() => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItem label="Item 1" />
|
||||
<MenuItem label="Item 2" />
|
||||
</MenuGroup>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => (
|
||||
<div data-testid="context-menu-target" onClick={(ev) => openMenu({ x: ev.pageX, y: ev.pageY })}>
|
||||
Click me
|
||||
</div>
|
||||
)}
|
||||
</WithContextMenu>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('context-menu-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 2')).not.toBeInTheDocument();
|
||||
|
||||
// Simulate key down to open context menu
|
||||
await userEvent.click(screen.getByTestId('context-menu-target'));
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not open menu when openMenu is called with undefined', async () => {
|
||||
render(
|
||||
<WithContextMenu
|
||||
renderMenuItems={() => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItem label="Item 1" />
|
||||
<MenuItem label="Item 2" />
|
||||
</MenuGroup>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => (
|
||||
<div data-testid="context-menu-target" onClick={() => openMenu(undefined)}>
|
||||
Click me
|
||||
</div>
|
||||
)}
|
||||
</WithContextMenu>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('context-menu-target')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 2')).not.toBeInTheDocument();
|
||||
|
||||
// Simulate click to open context menu
|
||||
await userEvent.click(screen.getByTestId('context-menu-target'));
|
||||
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Item 2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,23 @@ import * as React from 'react';
|
||||
|
||||
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
||||
|
||||
/**
|
||||
* This callback supports several ways to provide the x/y coordinates to open the context menu:
|
||||
* - MouseEvent, to open the menu at the mouse position
|
||||
* - SyntheticEvent, to open the menu at the location of the currentTarget element for non-mouse events
|
||||
* - An object with x and y coordinates to open the menu at a specific position, for other use-cases
|
||||
*/
|
||||
export type WithContextMenuOpenMenuCallback = (
|
||||
e:
|
||||
| React.MouseEvent<HTMLElement | SVGElement>
|
||||
| React.SyntheticEvent<HTMLElement | SVGElement>
|
||||
| { x: number; y: number }
|
||||
| undefined
|
||||
) => void;
|
||||
|
||||
export interface WithContextMenuProps {
|
||||
/** Menu item trigger that accepts openMenu prop */
|
||||
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
|
||||
children: (props: { openMenu: WithContextMenuOpenMenuCallback }) => JSX.Element;
|
||||
/** A function that returns an array of menu items */
|
||||
renderMenuItems: () => React.ReactNode;
|
||||
/** On menu open focus the first element */
|
||||
@@ -15,17 +29,43 @@ export interface WithContextMenuProps {
|
||||
export const WithContextMenu = ({ children, renderMenuItems, focusOnOpen = true }: WithContextMenuProps) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleOpenMenu: WithContextMenuOpenMenuCallback = React.useCallback((e) => {
|
||||
if (!e) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMenuOpen(true);
|
||||
|
||||
if ('pageX' in e && 'pageY' in e) {
|
||||
// Mouse event
|
||||
setMenuPosition({
|
||||
x: e.pageX,
|
||||
y: e.pageY - window.scrollY,
|
||||
});
|
||||
} else if ('currentTarget' in e) {
|
||||
// Element - calculate position from element's bounding rect
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setMenuPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
});
|
||||
}
|
||||
} else if ('x' in e && 'y' in e && typeof e.x === 'number') {
|
||||
// Position object
|
||||
setMenuPosition({
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
});
|
||||
} else if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('WithContextMenu: Unsupported parameter to openMenu:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
openMenu: (e) => {
|
||||
setIsMenuOpen(true);
|
||||
setMenuPosition({
|
||||
x: e.pageX,
|
||||
y: e.pageY - window.scrollY,
|
||||
});
|
||||
},
|
||||
})}
|
||||
{children({ openMenu: handleOpenMenu })}
|
||||
|
||||
{isMenuOpen && (
|
||||
<ContextMenu
|
||||
|
||||
@@ -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, WithContextMenuOpenMenuCallback } 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?: WithContextMenuOpenMenuCallback;
|
||||
targetClassName?: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +46,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
|
||||
active={item.active}
|
||||
onClick={item.onClick}
|
||||
className={styles.itemWrapper}
|
||||
tabIndex={0}
|
||||
/>
|
||||
))}
|
||||
</MenuGroup>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Group } from '@visx/group';
|
||||
import Pie, { PieArcDatum, ProvidedProps } from '@visx/shape/lib/shapes/Pie';
|
||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { UseTooltipParams } from '@visx/tooltip/lib/hooks/useTooltip';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
GrafanaTheme2,
|
||||
DataHoverClearEvent,
|
||||
DataHoverEvent,
|
||||
LinkModel,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { SortOrder, VizTooltipOptions } from '@grafana/schema';
|
||||
import {
|
||||
useTheme2,
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
SeriesTable,
|
||||
usePanelContext,
|
||||
} from '@grafana/ui';
|
||||
import { getTooltipContainerStyles, useComponentInstanceId } from '@grafana/ui/internal';
|
||||
import { DataLinksContextMenuApi, getTooltipContainerStyles, useComponentInstanceId } from '@grafana/ui/internal';
|
||||
|
||||
import { PieChartType, PieChartLabels } from './panelcfg.gen';
|
||||
import { filterDisplayItems, sumDisplayItemsReducer } from './utils';
|
||||
@@ -121,19 +123,18 @@ export const PieChart = ({
|
||||
|
||||
if (arc.data.hasLinks && arc.data.getLinks) {
|
||||
return (
|
||||
<DataLinksContextMenu key={arc.index} links={arc.data.getLinks}>
|
||||
{(api) => (
|
||||
<PieSlice
|
||||
tooltip={tooltip}
|
||||
highlightState={highlightState}
|
||||
arc={arc}
|
||||
pie={pie}
|
||||
fill={getGradientColor(color)}
|
||||
openMenu={api.openMenu}
|
||||
tooltipOptions={tooltipOptions}
|
||||
/>
|
||||
)}
|
||||
</DataLinksContextMenu>
|
||||
<PieSliceWithDataLinks
|
||||
key={arc.index}
|
||||
arc={arc}
|
||||
pie={pie}
|
||||
highlightState={highlightState}
|
||||
fill={getGradientColor(color)}
|
||||
tooltip={tooltip}
|
||||
tooltipOptions={tooltipOptions}
|
||||
outerRadius={layout.outerRadius}
|
||||
innerRadius={layout.innerRadius}
|
||||
links={arc.data.getLinks}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -145,6 +146,8 @@ export const PieChart = ({
|
||||
pie={pie}
|
||||
fill={getGradientColor(color)}
|
||||
tooltipOptions={tooltipOptions}
|
||||
outerRadius={layout.outerRadius}
|
||||
innerRadius={layout.innerRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -193,41 +196,242 @@ interface SliceProps {
|
||||
fill: string;
|
||||
tooltip: UseTooltipParams<SeriesTableRowProps[]>;
|
||||
tooltipOptions: VizTooltipOptions;
|
||||
openMenu?: (event: React.MouseEvent<SVGElement>) => void;
|
||||
openMenu?: DataLinksContextMenuApi['openMenu'];
|
||||
outerRadius: number;
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
function PieSlice({ arc, pie, highlightState, openMenu, fill, tooltip, tooltipOptions }: SliceProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const { eventBus } = usePanelContext();
|
||||
interface PieSliceWithDataLinksProps extends Omit<SliceProps, 'openMenu'> {
|
||||
links: () => LinkModel[];
|
||||
}
|
||||
|
||||
const onMouseOut = useCallback(
|
||||
(event: React.MouseEvent<SVGGElement>) => {
|
||||
interface PieChartDataLinksContextMenuProps {
|
||||
links: () => LinkModel[];
|
||||
children: (props: { openMenu?: DataLinksContextMenuApi['openMenu'] }) => React.ReactElement;
|
||||
elementRef: React.RefObject<SVGGElement>;
|
||||
publishDataHoverEvent: (raw: Event | React.SyntheticEvent) => void;
|
||||
publishDataHoverClearEvent: (raw: Event | React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around DataLinksContextMenu that adds event handlers to the anchor element
|
||||
* when it's created (for single-link case). This keeps event handler logic co-located
|
||||
* with component creation rather than in a separate useEffect in PieSlice.
|
||||
*/
|
||||
function PieChartDataLinksContextMenu({
|
||||
links,
|
||||
children,
|
||||
elementRef,
|
||||
publishDataHoverEvent,
|
||||
publishDataHoverClearEvent,
|
||||
}: PieChartDataLinksContextMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentAnchor = container.querySelector('a');
|
||||
if (!parentAnchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure anchor is focusable
|
||||
if (parentAnchor.getAttribute('tabIndex') === '-1') {
|
||||
parentAnchor.removeAttribute('tabIndex');
|
||||
}
|
||||
|
||||
// Ensure SVG element is not focusable
|
||||
if (elementRef.current) {
|
||||
const ensureNotFocusable = () => {
|
||||
if (elementRef.current && elementRef.current.getAttribute('tabIndex') !== '-1') {
|
||||
elementRef.current.setAttribute('tabIndex', '-1');
|
||||
}
|
||||
};
|
||||
ensureNotFocusable();
|
||||
setTimeout(ensureNotFocusable, 0);
|
||||
}
|
||||
|
||||
const handleAnchorFocus = publishDataHoverEvent;
|
||||
const handleAnchorBlur = publishDataHoverClearEvent;
|
||||
|
||||
const handleAnchorKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && elementRef.current) {
|
||||
elementRef.current.setAttribute('tabIndex', '-1');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnchorFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target;
|
||||
if (target instanceof Element && target !== parentAnchor && parentAnchor.contains(target)) {
|
||||
e.stopPropagation();
|
||||
parentAnchor.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target;
|
||||
if (target === parentAnchor || (target instanceof Node && parentAnchor.contains(target))) {
|
||||
handleAnchorFocus(e);
|
||||
}
|
||||
};
|
||||
|
||||
parentAnchor.addEventListener('focus', handleAnchorFocus, true);
|
||||
parentAnchor.addEventListener('focusin', handleFocusIn, true);
|
||||
parentAnchor.addEventListener('blur', handleAnchorBlur, true);
|
||||
parentAnchor.addEventListener('keydown', handleAnchorKeyDown, true);
|
||||
parentAnchor.addEventListener('focusin', handleAnchorFocusIn, true);
|
||||
|
||||
return () => {
|
||||
parentAnchor.removeEventListener('focus', handleAnchorFocus, true);
|
||||
parentAnchor.removeEventListener('focusin', handleFocusIn, true);
|
||||
parentAnchor.removeEventListener('blur', handleAnchorBlur, true);
|
||||
parentAnchor.removeEventListener('keydown', handleAnchorKeyDown, true);
|
||||
parentAnchor.removeEventListener('focusin', handleAnchorFocusIn, true);
|
||||
};
|
||||
}, [elementRef, publishDataHoverEvent, publishDataHoverClearEvent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'contents' }}>
|
||||
<DataLinksContextMenu links={links}>{children}</DataLinksContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that wraps PieSlice with DataLinksContextMenu and handles event listeners
|
||||
* for the anchor element. This keeps event handler logic co-located with component creation.
|
||||
*/
|
||||
function PieSliceWithDataLinks({
|
||||
arc,
|
||||
pie,
|
||||
highlightState,
|
||||
fill,
|
||||
tooltip,
|
||||
tooltipOptions,
|
||||
outerRadius,
|
||||
innerRadius,
|
||||
links,
|
||||
}: PieSliceWithDataLinksProps) {
|
||||
const { eventBus } = usePanelContext();
|
||||
const elementRef = useRef<SVGGElement>(null);
|
||||
|
||||
const publishDataHoverEvent = useCallback(
|
||||
(raw: Event | React.SyntheticEvent) => {
|
||||
eventBus?.publish({
|
||||
type: DataHoverClearEvent.type,
|
||||
type: DataHoverEvent.type,
|
||||
payload: {
|
||||
raw: event,
|
||||
raw,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dataId: arc.data.display.title,
|
||||
},
|
||||
});
|
||||
},
|
||||
[eventBus, arc.data.display.title]
|
||||
);
|
||||
|
||||
const publishDataHoverClearEvent = useCallback(
|
||||
(raw: Event | React.SyntheticEvent) => {
|
||||
eventBus?.publish({
|
||||
type: DataHoverClearEvent.type,
|
||||
payload: {
|
||||
raw,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dataId: arc.data.display.title,
|
||||
},
|
||||
});
|
||||
},
|
||||
[eventBus, arc.data.display.title]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataLinksContextMenu links={links}>
|
||||
{(api) => (
|
||||
<PieSlice
|
||||
tooltip={tooltip}
|
||||
highlightState={highlightState}
|
||||
arc={arc}
|
||||
pie={pie}
|
||||
fill={fill}
|
||||
openMenu={api.openMenu}
|
||||
tooltipOptions={tooltipOptions}
|
||||
outerRadius={outerRadius}
|
||||
innerRadius={innerRadius}
|
||||
elementRef={elementRef}
|
||||
/>
|
||||
)}
|
||||
</DataLinksContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function PieSlice({
|
||||
arc,
|
||||
pie,
|
||||
highlightState,
|
||||
openMenu,
|
||||
fill,
|
||||
tooltip,
|
||||
tooltipOptions,
|
||||
outerRadius,
|
||||
innerRadius,
|
||||
elementRef: externalElementRef,
|
||||
}: SliceProps & { elementRef?: React.RefObject<SVGGElement> }) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const { eventBus } = usePanelContext();
|
||||
const internalElementRef = useRef<SVGGElement>(null);
|
||||
const elementRef = externalElementRef || internalElementRef;
|
||||
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hasDataLinksDirect = Boolean(arc.data.hasLinks && arc.data.getLinks);
|
||||
const hasDataLinks = Boolean(openMenu) || hasDataLinksDirect;
|
||||
const shouldBeFocusable = hasDataLinks && Boolean(openMenu);
|
||||
|
||||
const publishDataHoverEvent = useCallback(
|
||||
(raw: Event | React.SyntheticEvent) => {
|
||||
eventBus?.publish({
|
||||
type: DataHoverEvent.type,
|
||||
payload: {
|
||||
raw,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dataId: arc.data.display.title,
|
||||
},
|
||||
});
|
||||
},
|
||||
[eventBus, arc.data.display.title]
|
||||
);
|
||||
|
||||
const publishDataHoverClearEvent = useCallback(
|
||||
(raw: Event | React.SyntheticEvent) => {
|
||||
eventBus?.publish({
|
||||
type: DataHoverClearEvent.type,
|
||||
payload: {
|
||||
raw,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dataId: arc.data.display.title,
|
||||
},
|
||||
});
|
||||
},
|
||||
[eventBus, arc.data.display.title]
|
||||
);
|
||||
|
||||
const onMouseOut = useCallback(
|
||||
(event: React.MouseEvent<SVGGElement>) => {
|
||||
publishDataHoverClearEvent(event);
|
||||
tooltip.hideTooltip();
|
||||
},
|
||||
[eventBus, arc, tooltip]
|
||||
[publishDataHoverClearEvent, tooltip]
|
||||
);
|
||||
|
||||
const onMouseMoveOverArc = useCallback(
|
||||
(event: React.MouseEvent<SVGGElement>) => {
|
||||
eventBus?.publish({
|
||||
type: DataHoverEvent.type,
|
||||
payload: {
|
||||
raw: event,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dataId: arc.data.display.title,
|
||||
},
|
||||
});
|
||||
publishDataHoverEvent(event);
|
||||
|
||||
const owner = event.currentTarget.ownerSVGElement;
|
||||
|
||||
@@ -240,18 +444,96 @@ function PieSlice({ arc, pie, highlightState, openMenu, fill, tooltip, tooltipOp
|
||||
});
|
||||
}
|
||||
},
|
||||
[eventBus, arc, tooltip, pie, tooltipOptions]
|
||||
[publishDataHoverEvent, tooltip, pie, tooltipOptions, arc]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<SVGGElement>) => {
|
||||
if (hasDataLinks && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (elementRef.current && openMenu) {
|
||||
// Calculate position from arc center for pie chart
|
||||
const arcCenterAngle = (arc.startAngle + arc.endAngle) / 2;
|
||||
const arcRadius = (outerRadius + innerRadius) / 2;
|
||||
const centerX = Math.cos(arcCenterAngle - Math.PI / 2) * arcRadius;
|
||||
const centerY = Math.sin(arcCenterAngle - Math.PI / 2) * arcRadius;
|
||||
|
||||
const svgElement = elementRef.current.ownerSVGElement;
|
||||
const svgRect = svgElement?.getBoundingClientRect();
|
||||
|
||||
if (svgRect) {
|
||||
const position = {
|
||||
x: svgRect.left + svgRect.width / 2 + centerX,
|
||||
y: svgRect.top + svgRect.height / 2 + centerY + window.scrollY,
|
||||
};
|
||||
|
||||
openMenu(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[hasDataLinks, openMenu, arc, outerRadius, innerRadius, elementRef]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: React.FocusEvent<SVGGElement>) => {
|
||||
// Clear any pending blur timeout - focus has returned to this element or a related element
|
||||
// This prevents clearing the hover state when focus moves between related UI elements
|
||||
// (e.g., from the pie slice to an opened context menu)
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
blurTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
publishDataHoverEvent(event);
|
||||
},
|
||||
[publishDataHoverEvent]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: React.FocusEvent<SVGGElement>) => {
|
||||
// Delay clearing the hover state to handle focus transitions between related elements.
|
||||
// When a context menu opens, focus may move from the pie slice to the menu, triggering
|
||||
// a blur event. The timeout allows us to check if focus has actually left the component
|
||||
// or just moved to a related element (like the menu). If focus returns within 100ms,
|
||||
// handleFocus will clear this timeout.
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
// Only clear hover state if focus has actually left this element
|
||||
if (elementRef.current && document.activeElement !== elementRef.current) {
|
||||
publishDataHoverClearEvent(event);
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 100);
|
||||
},
|
||||
[publishDataHoverClearEvent, elementRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pieStyle = getSvgStyle(highlightState, styles);
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={elementRef}
|
||||
key={arc.data.display.title}
|
||||
className={pieStyle}
|
||||
onMouseMove={tooltipOptions.mode !== 'none' ? onMouseMoveOverArc : undefined}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={openMenu}
|
||||
tabIndex={shouldBeFocusable ? 0 : -1}
|
||||
role={shouldBeFocusable ? 'link' : undefined}
|
||||
aria-label={t('piechart.data-link-label', '{{title}} - Data link', { title: arc.data.display.title })}
|
||||
onKeyDown={shouldBeFocusable ? handleKeyDown : undefined}
|
||||
onFocus={hasDataLinks ? handleFocus : undefined}
|
||||
onBlur={hasDataLinks ? handleBlur : undefined}
|
||||
data-testid={selectors.components.Panels.Visualization.PieChart.svgSlice}
|
||||
>
|
||||
<path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.background.primary} strokeWidth={1} />
|
||||
@@ -438,17 +720,20 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
}),
|
||||
svgArg: {
|
||||
normal: css({
|
||||
outline: 'none',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
transition: 'all 200ms ease-in-out',
|
||||
},
|
||||
}),
|
||||
highlighted: css({
|
||||
outline: 'none',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
transition: 'all 200ms ease-in-out',
|
||||
},
|
||||
transform: 'scale3d(1.03, 1.03, 1)',
|
||||
}),
|
||||
deemphasized: css({
|
||||
outline: 'none',
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
transition: 'all 200ms ease-in-out',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user