Compare commits

...

10 Commits

Author SHA1 Message Date
Paul Marbach
849098bcf0 wip: improve the working branch 2026-01-09 17:11:30 -05:00
Abhijnya002
f70bc80a5c docs: add comments explaining blur timeout pattern
- Explain why blur timeout is needed for focus transitions
- Clarify that it handles focus moving between related UI elements
- Addresses review feedback requesting explanation of blur timeout
2026-01-06 19:34:20 -05:00
Abhijnya002
254a7b9971 feat: extend DataLinksContextMenu to support keyboard events
- Update DataLinksContextMenu API to accept position objects for keyboard events
- Add getMenuPosition helper function to calculate position from element
- Update WithContextMenu to handle position objects and elements
- Simplify pie chart keyboard handler to use position object API
- Position calculation logic now in core component (DataLinksContextMenu)
- Addresses review feedback about keyboard accessibility and position calculation
2026-01-06 19:29:28 -05:00
Abhijnya002
838bc756db refactor: move event handler logic into wrapper component
- Create PieChartDataLinksContextMenu wrapper that handles anchor event listeners
- Create PieSliceWithDataLinks component that uses the wrapper
- Remove large useEffect from PieSlice that was finding anchor after creation
- Event handler logic is now co-located with DataLinksContextMenu usage
- Addresses review feedback about handler management being outside component creation
2026-01-06 19:23:40 -05:00
Abhijnya002
bb012056eb refactor: extract repeated event dispatch into useCallback
- Create publishDataHoverEvent and publishDataHoverClearEvent useCallback functions
- Replace all repeated eventBus.publish calls with the new callbacks
- Update dependency arrays to use the new callbacks
- Addresses review feedback on code duplication
2026-01-06 19:18:33 -05:00
Abhijnya002
4ac1bbc657 refactor: simplify ensureNotFocusable calls
- Remove unnecessary 100ms setTimeout call
- Keep immediate call and setTimeout(0) to handle React render timing
- Add comments explaining the rationale
- Addresses review feedback on code clarity
2026-01-06 19:14:29 -05:00
Abhijnya002
d46947ccb2 refactor: move outline style from inline to getSvgStyle CSS
- Move outline: 'none' from inline style prop to CSS styles
- Add outline: 'none' to all svgArg states (normal, highlighted, deemphasized)
- Addresses review feedback on code organization
2026-01-06 19:11:51 -05:00
Abhijnya002
45bdeefbdf fix: use i18n for aria-label and always set it in pie chart
- Replace hardcoded aria-label string with @grafana/i18n translation
- Always set aria-label instead of conditionally setting to undefined
- Addresses review feedback on internationalization and accessibility
2026-01-06 19:09:22 -05:00
Abhijnya002
eb3df4fa00 Remove data links sorting from pie chart slices
Remove the sort() call that was reordering pie slices based on data links presence. Pie slices should maintain their original order regardless of data links.
2026-01-05 12:37:34 -05:00
Abhijnya002
65919fc0bf fix(piechart): add visible keyboard focus indicator for data links
Fixes #114227

Adds visible focus indicator to data links in pie chart visualization
to improve keyboard navigation accessibility.

- Added keyboard focus handlers for slices with data links
- Implemented highlight on focus using DataHoverEvent system
- Added Enter key support to activate data links
- Handled both single and multiple link cases
- Ensured proper tab order by sorting slices with data links first
- Added proper ARIA attributes (role, aria-label) for accessibility
2025-12-27 00:02:34 -05:00
6 changed files with 650 additions and 48 deletions

99
CONTRIBUTION.md Normal file
View 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

View File

@@ -17,5 +17,9 @@ test.describe(
);
await expect(pieChartSlices).toHaveCount(5);
});
describe('keyboard accessibility', () => {
// FIXME: DATALINKS ACCESSIBILITY TESTS HERE
});
}
);

View File

@@ -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();
});
});

View File

@@ -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

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, 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>

View File

@@ -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',
},