Repository navigation
[charts] Focus the clicked item for keyboard navigation - #23247
Conversation
…ible `isFocused` conflated "the chart subtree owns the DOM focus" with "the focus indicator is rendered". Split the second meaning into `isFocusVisible` so an upcoming pointer-driven focus can set the item without revealing it. The focus indicator, highlight and tooltip selectors now require both flags. `selectorChartsFocusedOrToFocusedItem` stays ungated so the accessibility description keeps working. No behavior change: the focusin handler sets both flags.
Adds `chartsAccessibilityProxyRef` to `useChartElementRef` and a `focusAccessibilityProxy` helper, so the keyboard navigation plugin can move the DOM focus into the chart without querying the layer container, whose subtree also holds the zoom slider and brush focusable elements.
…ance `focusItem` makes any item the one keyboard navigation resumes from and moves the DOM focus into the chart. A focus-visible intent ref, fed by pointerdown, keydown and focusout on the layer container, decides whether the focus indicator shows: pointer driven focus stays hidden until the user presses a key, unless `focusItemOnClick` is set. The highlight and tooltip only switch to keyboard mode when the focus is visible, so clicking an item no longer detaches the tooltip from the pointer. Navigation keys now always reveal the focus, even at the edges of a series that does not cycle, where the focused item does not move.
Threads the new keyboard navigation param through the container, radar and heatmap prop hooks, and regenerates the propTypes and API documentation.
Clicking an item now makes it the item keyboard navigation resumes from, whether or not an `onItemClick` callback is set. `useInteractionItemProps` gained a merged `onClick` covering pie, line marks, scatter markers, radar marks, funnel sections, sankey nodes and links, and map shapes. The paths that have to compute the index themselves call `focusItem` directly: bars and heatmap cells through their container hit test, line and area paths through the axis index, radar areas through the click angle, and scatter through the closest point, so clicking next to a point focuses it. Radial bar and radial line charts register `useChartItemClick`, which also gives them the `onItemClick` callback they were missing. A click that resolves no item still moves the DOM focus into the chart, keeping the previously focused item. Known gap: the WebGL range bar plot has no click path, so range bar click to focus only works with the SVG plot.
Covers the two modes on bar charts, the closest point resolution on scatter, line marks and paths, pie arcs and radar marks, and asserts the highlight stays pointer driven and `onItemClick` still fires exactly once. The accessibility proxy root keeps a `tabIndex` of -1 once an announcer child owns the tab stop. Without it the browser blurred the root during the internal hand-off, which read as the focus leaving the chart and revealed a focus set by a click. Keyboard modality is tracked on the document so tabbing back in from an outside element shows the focus again.
Adds a section to the accessibility page explaining the two focus modes, and a toggle to the keyboard navigation demo. Also lists the map, radial bar and radial line charts among the ones supporting keyboard navigation.
Clicking the empty space of a cartesian chart only gave the chart focus, which felt unresponsive. It now resolves the axis under the pointer and focuses the item it points at, taken from the series being navigated, or from the first series when none is focused yet. Clicks outside of any axis, and charts without a cartesian axis, keep the previous behavior of only focusing the chart. A pointer interaction that already resolved an item skips the fallback, so the closest point of a scatter chart is not overwritten by an axis index.
Deploy previewBundle size
PerformanceTotal duration: 1,859.86 ms 🔺+401.32 ms(+27.5%) | Renders: 63 (+0) | Paint: 2,514.44 ms 🔺+589.70 ms(+30.6%)
…and 19 more (+2 within noise) — details Metric alarms
…and 16 more metric alarms — details Check out the code infra dashboard for more information about this PR. |
…t name The accessibility page documented the charts theme defaults under `MuiChartDataProvider`, which matches no component, so the snippet silently did nothing. The name is `MuiChartsDataProvider`. Adds a test covering `focusItemOnClick` set through the theme, which is what caught it.
There was a problem hiding this comment.
Pull request overview
Adds “click-to-focus” support for Charts keyboard navigation: clicking a chart item becomes the starting point for arrow-key navigation and triggers immediate screen-reader narration, while keeping the focus indicator hidden unless it was already visible or focusItemOnClick is enabled.
Changes:
- Split keyboard-navigation “chart has DOM focus” vs “focus indicator is visible” via
isFocused+ newisFocusVisible, and wire focus visibility intent across pointer vs keyboard interactions. - Introduce
instance.focusItem()/instance.focusChart()and route click handlers across many series implementations to update the focused item consistently. - Add axis-based fallback resolution for background clicks (cartesian charts), and expand tests + docs to cover the new behavior and the
focusItemOnClickprop.
Reviewed changes
Copilot reviewed 107 out of 107 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/x-charts/src/SparkLineChart/SparkLineChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/ScatterChart/ScatterChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/ScatterChart/Scatter.tsx | Merges click handling into interaction props; adds keyboard-nav dependency. |
| packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx | Merges click handling into interaction props; adds keyboard-nav dependency. |
| packages/x-charts/src/RadarChart/useRadarChartProps.ts | Threads focusItemOnClick through chart props. |
| packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts | Ensures interaction props include keyboard focus support. |
| packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx | Focuses clicked radar marks (but currently gates pointer events on onItemClick). |
| packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesArea.tsx | Focuses clicked radar area using click angle to resolve dataIndex. |
| packages/x-charts/src/RadarChart/RadarChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/PieChart/PieChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/PieChart/PieArc.tsx | Uses merged interaction onClick so click-to-focus and consumer click both run once. |
| packages/x-charts/src/LineChart/useLineItemClickHandler.ts | Resolves dataIndex from axis on path clicks and focuses the resolved item. |
| packages/x-charts/src/LineChart/MarkElement.tsx | Ensures mark clicks focus the item via merged interaction onClick. |
| packages/x-charts/src/LineChart/LinePlot.tsx | Wires line-path click to resolver handler and passes cursor as needed. |
| packages/x-charts/src/LineChart/LineElement.tsx | Allows cursor to be overridden by caller. |
| packages/x-charts/src/LineChart/LineChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/LineChart/CircleMarkElement.tsx | Ensures circle mark clicks focus the item via merged interaction onClick. |
| packages/x-charts/src/LineChart/AreaPlot.tsx | Wires area-path click to resolver handler and passes cursor as needed. |
| packages/x-charts/src/LineChart/AreaElement.tsx | Allows cursor to be overridden by caller. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts | Adds axis-based item resolver for background clicks. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts | Adds unit tests for axis-based background click resolution. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts | Adds focusItem/focusChart instance APIs and isFocusVisible state + focusItemOnClick param. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts | Implements focus intent tracking, click-to-focus, and axis fallback; adjusts focus restore behavior. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts | Gates focus-indicator/highlight/tooltip selectors on “focus visible”; adds selectorChartsIsFocusVisible. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts | Adds selector coverage for visible vs hidden focus state. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx | Adds click-to-focus integration tests across chart types and modes. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts | Declares keyboard navigation as an optional dependency for item-click plugin. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts | Focuses clicked item before calling onItemClick, when keyboard navigation is available. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts | Adds keyboard navigation to dependencies. |
| packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts | Focuses the closest scatter point on tap/click, then calls onItemClick. |
| packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.types.ts | Adds chartsAccessibilityProxyRef to instance. |
| packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.ts | Creates and exposes chartsAccessibilityProxyRef. |
| packages/x-charts/src/internals/components/ChartsAccessibilityProxy/index.ts | Exports new focusAccessibilityProxy helper. |
| packages/x-charts/src/internals/components/ChartsAccessibilityProxy/focusAccessibilityProxy.ts | Adds helper to focus the correct proxy element without scrolling. |
| packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx | Fixes tabIndex hand-off behavior and wires proxy ref into chart instance. |
| packages/x-charts/src/hooks/useInteractionItemProps.ts | Adds merged onClick that focuses item then calls consumer handler; updates helper types. |
| packages/x-charts/src/ChartsContainer/useChartsContainerProps.ts | Threads focusItemOnClick into container props. |
| packages/x-charts/src/ChartsContainer/ChartsContainer.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts/src/BarChart/useRegisterItemClickHandlers.ts | Focuses bar item on click even without onItemClick; adds keyboard-nav dependency. |
| packages/x-charts/src/BarChart/BarChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx | Uses merged interaction onClick to focus node then call consumer handler. |
| packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx | Uses merged interaction onClick to focus link then call consumer handler. |
| packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts | Threads focusItemOnClick through heatmap props. |
| packages/x-charts-pro/src/Heatmap/Heatmap.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/FunnelChart/FunnelSection.tsx | Uses merged interaction onClick to focus section then call consumer handler. |
| packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx | Adds focusItemOnClick and introduces public onItemClick prop types. |
| packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts | Adds useChartItemClick plugin to enable click-to-focus + onItemClick. |
| packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx | Adds focusItemOnClick and introduces public onItemClick prop types. |
| packages/x-charts-premium/src/RadialBarChart/RadialBarChart.plugins.ts | Adds useChartItemClick plugin to enable click-to-focus + onItemClick. |
| packages/x-charts-premium/src/Map/MapShape.tsx | Uses merged interaction onClick to focus shape then call consumer handler. |
| packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx | Adds focusItemOnClick PropTypes entry. |
| packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx | Focuses clicked range-bar items (SVG renderer). |
| packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx | Adds focusItemOnClick PropTypes entry. |
| docs/translations/api-docs/charts/spark-line-chart/spark-line-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/scatter-chart/scatter-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/scatter-chart-pro/scatter-chart-pro.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/scatter-chart-premium/scatter-chart-premium.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/sankey-data-provider/sankey-data-provider.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/sankey-chart/sankey-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json | Documents focusItemOnClick and new onItemClick. |
| docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json | Documents focusItemOnClick and new onItemClick. |
| docs/translations/api-docs/charts/radar-chart/radar-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/radar-chart-pro/radar-chart-pro.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/pie-chart/pie-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/pie-chart-pro/pie-chart-pro.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/line-chart/line-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/line-chart-pro/line-chart-pro.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/heatmap/heatmap.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/heatmap-premium/heatmap-premium.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/funnel-chart/funnel-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/charts-container/charts-container.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/bar-chart/bar-chart.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/bar-chart-pro/bar-chart-pro.json | Documents focusItemOnClick. |
| docs/translations/api-docs/charts/bar-chart-premium/bar-chart-premium.json | Documents focusItemOnClick. |
| docs/pages/x/api/charts/spark-line-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/scatter-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/scatter-chart-pro.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/scatter-chart-premium.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/sankey-data-provider.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/sankey-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/radial-line-chart.json | Adds focusItemOnClick + onItemClick API page metadata. |
| docs/pages/x/api/charts/radial-bar-chart.json | Adds focusItemOnClick + onItemClick API page metadata. |
| docs/pages/x/api/charts/radar-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/radar-chart-pro.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/pie-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/pie-chart-pro.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/line-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/line-chart-pro.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/heatmap.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/heatmap-premium.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/funnel-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/charts-container.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/bar-chart.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/bar-chart-pro.json | Adds focusItemOnClick API page metadata. |
| docs/pages/x/api/charts/bar-chart-premium.json | Adds focusItemOnClick API page metadata. |
| docs/data/charts/accessibility/KeyboardNavigation.tsx | Updates the demo to toggle and pass focusItemOnClick. |
| docs/data/charts/accessibility/KeyboardNavigation.js | Generated demo output updated to match TSX demo changes. |
| docs/data/charts/accessibility/accessibility.md | Documents click-to-focus behavior and expands supported chart list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pointerEvents={onItemClick ? undefined : 'none'} | ||
| onClick={(event) => | ||
| onItemClick?.(event, { type: 'radar', seriesId: id, dataIndex: index }) | ||
| } | ||
| onClick={(event) => { | ||
| const identifier = { type: 'radar', seriesId: id, dataIndex: index } as const; | ||
| instance.focusItem?.(identifier); | ||
| onItemClick?.(event, identifier); | ||
| }} | ||
| cursor={onItemClick ? 'pointer' : 'unset'} | ||
| {...other} |
There was a problem hiding this comment.
Click-to-focus does work on radar without onMarkClick — I checked before changing anything. The marks are pointer-transparent, so the click lands on the series area underneath, which resolves the data index from the click angle via getRotationIndex. Added a test covering exactly that case (focuses the mark through the area when no click callback is set).
Making the marks always pointer-active would be a regression: they carry no interaction props, so they'd start intercepting pointer events from the area and break the hover highlight and tooltip on every mark.
That said, chasing this did surface a real bug next door, so thanks — RadarSeriesPlot (the composition component) spread the interaction props after its own onClick, so the handler this PR adds was replacing onAreaClick instead of running alongside it. Fixed in c78d2c8, with a regression test that fails without it.
It also showed my radar tests were too weak: they only asserted that some focus indicator appeared, which passes even when the click resolves nothing. They now assert which mark is focused.
RadarSeriesPlot, the composition component, spread the interaction props after its own `onClick`, so the click handler added for the keyboard focus replaced `onAreaClick` instead of running alongside it. It now merges both and focuses the clicked item, like the split RadarSeriesArea and RadarSeriesMarks already did. Also strengthens the radar click to focus tests, which only asserted that some indicator appeared and so passed even when the click resolved nothing.
The fallback read the data index off the first x or y axis, so on a chart with several axes a click could resolve against a scale the target series is not bound to and focus the wrong point. The target series is now resolved first, and its own `xAxisId` / `yAxisId` picks the axis, mirroring useLineItemClickHandler. Also keeps the scatter marker click alive during a zoom interaction. Moving the callback into the interaction props had put it behind the `isInteracting` guard, which silently stopped `onItemClick` from firing mid zoom. Only the pointer handlers are dropped there, as before.
`updateFocus` hardcoded `isFocused: true`, which assumes the accessibility proxy is present to receive the focus. A composition that does not render it would get an indicator that no blur can clear, since the focusout listener lives on the same missing container. The flag is now read from the DOM, and the item is still stored so it is restored on the next focus. Also documents that the interaction props own the click, so consumers pass their callback through the options instead of setting `onClick` next to the spread.
Scatter markers and range bars derived their cursor from the presence of a click handler. Now that the handler is always attached to drive the keyboard focus, they showed a pointer cursor on charts that are not clickable. Both take the cursor from their plot instead, which derives it from `onItemClick` like the other series do. `ScatterMarkerProps` gained a `cursor` prop so custom markers can do the same, since `onClick` no longer tells them whether the chart is interactive.
Custom slot components have to forward the click handler for click to focus to work, and can no longer read `onClick` to tell whether the chart is interactive. Documents both, and how to reach the same behavior from a plot built from scratch.
`focusChart` and `focusItem` carried the same enabled check, visibility resolution, proxy focus and no-op comparison. Both now delegate to a single `applyFocus`, where an undefined item means focusing the chart alone, and `focusItem` only adds the identifier validation on top.
Registering `useChartItemClick` on the radial charts gave them click to focus, but it also added a public `onItemClick` they never had. That is a separate API addition, so it moves to its own pull request.
- The click fallback listens on the document, so it runs after the React handlers instead of before them. It was resolving an item on every click, only to be corrected by the series handler a moment later. - `restoreFocus` was `applyFocus` without the focusing step, which is already a no-op once the focus is inside the chart. It now delegates. - `focusChart` was only used by the fallback, so it is no longer part of the instance API. - Inlines a single use helper and folds the two symmetric visible index lookups into one.
The clicked item is resolved by the series that owns the click, not read from the highlight. Controlling the highlight stops the store from tracking the pointer, so a focus derived from it would follow the consumer instead. Pins that down.
| | <kbd class="key">Arrow Left</kbd>, <kbd class="key">Arrow Right</kbd> | Moves focus inside the series | | ||
| | <kbd class="key">Arrow Up</kbd>, <kbd class="key">Arrow Down</kbd> | Move focus between series | | ||
|
|
||
| ### Start from a clicked item |
There was a problem hiding this comment.
All series currently tells the chart which item the pointer is over, which is how the tooltip and the highlight work. So click-to-focus could in theory ignore clicks entirely and just focus whatever is currently hovered, which would be simpler than this pr.
That would drop the changes to useInteractionItemProps, so most of the diff, along with the merged click handler and the cursor adjustments it needed.
The problem is that highlightedItem and tooltipItem are controlled props. Once an app controls the highlight, setHighlight returns early and the store stops following the pointer at all (useChartHighlight.ts#L105), so the stored item is whatever the app decided rather than what was clicked. On those charts you'd click one bar and focus another, silently, and only for people using a documented API. The tooltip item has the same catch.
So there's no single reliable "item under the pointer" to read from, and each series has to report its own click instead.
@alexfauquette Question is, should highlight or tooltip control the focus? Or should the focus be independent? 🤔
There was a problem hiding this comment.
Question is, should highlight or tooltip control the focus?
IMO, it's different. The highlight could be sync between different chart for other components. Which make no sens for focus.
Or should the focus be independent? 🤔
Should we introduce an intermediate repesentation?
Disclaimer: I worked on it a long time ago so I might forget stuff
An initial reason why tooltip and highlight are handled differently because of edge cases. Mostly the fact that you can highlight a line series but not display a tooltip for it.
Currently, The tooltip and highlight work nearly the same way (When you see a setTooltipItem(), the setHighlight is not that far ;) )
And the swich between focused/highlighted/controlled item is done by selector
export const selectorChartsHighlightedItem = createSelectorMemoized(
selectHighlight,
selectorChartsKeyboardItem,
function selectorChartsHighlightedItem(highlight, keyboardItem) {
return highlight.isControlled || highlight.lastUpdate === 'pointer'
? highlight.item
: keyboardItem;
},
);Technically a solution could be:
- introduce an hovered item state purely handled by the library and stored in the interaction
- derive from this one the highlight/tooltip item from pointer. If highlight or tooltip is controlled, the selector just ignore it and return
highlight.itemandtooltip.item
That was my 2 cents on this topic
There was a problem hiding this comment.
@alexfauquette nice input, that managed to decrease the touched files from this PR by quite a few 😆
The fallback focused the chart whenever a click hit nothing. The drawing area is a rectangle, but the data rarely fills it, so clicking next to a pie, outside its circle, took the focus for a click that was not meant for the chart. Such clicks are now left alone. Cartesian charts are unaffected: a click in the plot still resolves through the axis.
Every series already reports the item the pointer is over, for the tooltip and the highlight, so the click needs no per series wiring of its own. That state was not usable directly because `highlightedItem` and `tooltipItem` are controlled props: once controlled, the store stops following the pointer. Adds `interaction.hoveredItem`, written by the same hover paths but never controlled, and focuses it on tap. This replaces the merged click handler in `useInteractionItemProps` and the click wiring of the pie, line, scatter, radar, funnel, sankey, map and range bar series, along with the cursor adjustments those needed. Touch has no hover phase, so the container hit test, the closest point and the radar area also resolve the item on `pointerdown`: a stationary tap does not produce a `pointermove`. The highlight and tooltip selectors are untouched, and can be derived from the hovered item separately.
It was registered inside the effect that binds the DOM listeners on the layer container, which it has nothing to do with. Also drops the radar wiring, which needs the pointer item to be resolved from the click angle, so it moves to its own pull request.
The radial charts ship `getItemAtPosition`, so they report the item under the pointer through the shared hit test and need no wiring of their own.
Clicking an item moved the highlight and the tooltip to the focused item, which detaches the tooltip from the pointer. The pointer is still on the chart though, so they have to keep following it. They now hand over to the focused item only when the last interaction came from the keyboard, tracked next to the focus visibility. The previous guard was the focus visibility alone, which let a click through whenever the focus was already visible or `focusItemOnClick` was set.
Touch clears the pointer item on `pointerleave`, right after the tap, and then emits its compatibility `mousedown`, which moves the focus to the body. Resolving and focusing in the same step therefore either read a cleared item or lost the focus a moment later. The tap now only resolves the item, and the click that closes the sequence focuses it. A mouse is unaffected: its `mousedown` comes before the tap.
… feat/charts-click-to-focus
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
…-to-focus # Conflicts: # docs/data/charts/accessibility/accessibility.md # docs/pages/x/api/charts/bar-chart-premium.json # docs/pages/x/api/charts/bar-chart-pro.json # docs/pages/x/api/charts/bar-chart.json # docs/pages/x/api/charts/charts-container.json # docs/pages/x/api/charts/funnel-chart.json # docs/pages/x/api/charts/heatmap-premium.json # docs/pages/x/api/charts/heatmap.json # docs/pages/x/api/charts/pie-chart-pro.json # docs/pages/x/api/charts/pie-chart.json # docs/pages/x/api/charts/radar-chart-pro.json # docs/pages/x/api/charts/radar-chart.json # docs/pages/x/api/charts/radial-bar-chart.json # docs/pages/x/api/charts/radial-line-chart.json # docs/pages/x/api/charts/sankey-chart.json # docs/pages/x/api/charts/sankey-data-provider.json # packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts # packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts
noraleonte
left a comment
There was a problem hiding this comment.
Looks good to me 👀
Leaving one small comment
| const item = pendingFocusItemRef.current; | ||
| pendingFocusItemRef.current = null; | ||
|
|
||
| if (item !== null) { |
There was a problem hiding this comment.
AI pointed this out, might be worth a check:
_ If the second click never reaches document (i.e. tap remembers the item, but handler has e.stopPropagation()), the item stays remembered with no expiry and fires on whatever the user clicks next._
| if (item !== null) { | |
| if (item !== null && chartsLayerContainerRef.current?.contains(event.target as Node)) |
There was a problem hiding this comment.
Def a problem, usually user interactions are complex due to the async nature 😢
There was a problem hiding this comment.
Good catch, that's a real one. Applied in 6077861.
I confirmed the failure mode before fixing it. The pending item is already cleared on every document click, so it can only ever fire once, but that one time can be a click anywhere on the page: stopPropagation keeps the originating click from reaching the listener, then the next click consumes the stale item. Since the ref is only reset on a pointerdown inside the chart, a click outside never resets it first.
Added a test that clicks a bar with a handler stopping the propagation, then clicks a button outside the chart, and asserts no item ends up focused. It fails without the guard (expected SVGRectElement to equal null).
A handler calling `stopPropagation` keeps the click from reaching the document listener, so the item the tap resolved stayed pending and was applied to whatever the user clicked next, including outside the chart. The listener now only focuses when the click happened inside the chart.
The merge took the master version of the generated files, and the regeneration that followed silently failed on an incompatible Node version, so `focusItemOnClick` was missing from the chart API pages.
… feat/charts-click-to-focus
Closes #23195
Clicking a chart item now makes it the active item for keyboard navigation and screen-reader narration, across every keyboard-navigable series.
Keyboard navigation is a great feature, but today the only way in is Tab — and then hoping you landed on the right proxy div — followed by repeated arrow presses to reach a distant point. A click gets you there directly.
Behavior
focusItemOnClick(new prop,falseby default)disableKeyboardNavigationThe two modes are the point: a mouse user never sees a focus ring appear out of nowhere, while a keyboard user keeps the ring they were already using.
Clicks that hit no item
Only focusing the chart felt unresponsive, so a click that hits no item falls back to the axis under the pointer: the
dataIndexcomes from the x axis (then the y axis), and the item is taken from the series being navigated, or from the first series when none is focused yet. Clicking the empty space above a bar focuses that bar's column.Charts with no cartesian axis, and clicks off the axis, keep the previous behavior of only focusing the chart while preserving the item.
Scatter charts configured with a hit area resolve the closest point first, so a near-miss click focuses the point rather than an axis index.
Implementation
keyboardNavigation.isFocusedconflated the chart owns the DOM focus with the focus indicator is rendered. It is split intoisFocusedand a newisFocusVisible; the indicator, highlight and tooltip selectors require both, whileselectorChartsFocusedOrToFocusedItemstays ungated so the accessibility description keeps working — that is what makes a click announce without showing a ring.instance.focusItem()/focusChart()are new. Visibility comes from a focus-visible intent ref:pointerdownon the chart sets it tofocusItemOnClick || <already visible>, and any documentkeydownsets it true (which covers tabbing in from outside).Click wiring is split in two.
useInteractionItemPropsgained a mergedonClick, covering pie, line marks, scatter markers, funnel sections, sankey nodes and links, and map shapes — merging inside the hook because the spread order relative toonClickwas inconsistent across those components. The paths that must compute the index themselves callfocusItemdirectly: bars and heatmap cells through their container hit test, line and area paths through the axis index, radar areas and marks through the click angle or the mark index, and scatter through the closest point.On radar, which of the two paths runs depends on
onMarkClick: marks are pointer transparent without it, so the click falls through to the area and the index comes from the click angle. With it, the mark takes the click and its own index is used. Both paths are needed — dropping either leaves one configuration unable to focus.The highlight and tooltip only switch to keyboard mode when the focus is visible, so clicking an item no longer detaches the tooltip from the pointer.
Notes for review
useChartItemClick, which also exposes a publiconItemClickthey never had. That is an API addition rather than an accessibility fix, so it moved to [charts-premium] Add onItemClick to the radial charts #23253.restoreFocusno longer unconditionally flipslastUpdateto'keyboard'— only when the focus is visible. This is the behavioral change most worth a careful look.tabIndex={-1}once an announcer child owns the tab stop: the browser was blurring it mid hand-off, which read as the focus leaving the chart. And arrow keys at the edge of a non-cycling series now reveal the focus even though the focused item does not move.getItemAtAxisPosition.MuiChartDataProvider, which matches no component — the snippet silently did nothing. The name isMuiChartsDataProvider. Caught by adding a test forfocusItemOnClickset through the theme; the existingdisableKeyboardNavigationsnippet had the same typo.onItemClickmid zoom. Moving the callback into the interaction props initially put it behind theisInteractingguard, which silently suppressed it during a zoom gesture on the Pro/Premium async renderer. Only the pointer handlers are dropped there now, matching the previous behavior; covered by unit tests ongetMarkerInteractionProps.focusItemfocuses one announcer div and the message effect then flips to the other.isFocusVisibleis part of the state shipped through@mui/x-charts/internals.isFocusedkeeps its DOM-focus meaning; the indicator meaning moved to the new field.Testing
18 behavior tests in
useChartKeyboardNavigation.clickToFocus.test.tsxcovering both modes on bar charts, the closest point on scatter, line marks and paths, pie arcs, radar marks, the axis fallback, and focused-series preservation. Plus selector unit tests and 6 unit tests for the axis resolver. Every existingcheckClickEvent.test.tsxassertsonItemClickstill fires exactly once, guarding the merged-onClickrewrite.