From c768cf61f34a3b7dc576837987c89cb19db6363a Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:06:13 +0200 Subject: [PATCH 01/27] [charts] Split the keyboard focus state into isFocused and isFocusVisible `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. --- ...eChartKeyboardNavigation.selectors.test.ts | 65 +++++++++++++++++++ .../useChartKeyboardNavigation.selectors.ts | 23 +++++-- .../useChartKeyboardNavigation.ts | 3 + .../useChartKeyboardNavigation.types.ts | 5 ++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts new file mode 100644 index 0000000000000..d3795b2abf45c --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts @@ -0,0 +1,65 @@ +import { + selectorChartsFocusedItem, + selectorChartsFocusedOrToFocusedItem, + selectorChartsHasFocusedItem, + selectorChartsIsFocusVisible, + selectorChartsItemIsFocused, + selectorChartsKeyboardItem, +} from './useChartKeyboardNavigation.selectors'; +import type { UseChartKeyboardNavigationState } from './useChartKeyboardNavigation.types'; + +const item = { type: 'bar', seriesId: 's1', dataIndex: 2 } as const; + +const createState = ( + keyboardNavigation: Partial, +) => + ({ + keyboardNavigation: { + item: null, + isFocused: false, + isFocusVisible: false, + enabled: true, + ...keyboardNavigation, + }, + }) as UseChartKeyboardNavigationState; + +describe('useChartKeyboardNavigation selectors', () => { + describe('when the focus is visible', () => { + const state = createState({ item, isFocused: true, isFocusVisible: true }); + + it('exposes the focused item', () => { + expect(selectorChartsIsFocusVisible(state)).to.equal(true); + expect(selectorChartsFocusedItem(state)).to.deep.equal(item); + expect(selectorChartsHasFocusedItem(state)).to.equal(true); + expect(selectorChartsItemIsFocused(state, item)).to.equal(true); + expect(selectorChartsKeyboardItem(state)).to.deep.equal(item); + expect(selectorChartsFocusedOrToFocusedItem(state)).to.deep.equal(item); + }); + }); + + describe('when the chart owns the DOM focus but the focus is not visible', () => { + const state = createState({ item, isFocused: true, isFocusVisible: false }); + + it('hides the item from the focus indicator, highlight and tooltip selectors', () => { + expect(selectorChartsIsFocusVisible(state)).to.equal(false); + expect(selectorChartsFocusedItem(state)).to.equal(null); + expect(selectorChartsHasFocusedItem(state)).to.equal(false); + expect(selectorChartsItemIsFocused(state, item)).to.equal(false); + expect(selectorChartsKeyboardItem(state)).to.equal(null); + }); + + it('still exposes the item to the accessibility description', () => { + expect(selectorChartsFocusedOrToFocusedItem(state)).to.deep.equal(item); + }); + }); + + describe('when the chart is blurred', () => { + const state = createState({ item, isFocused: false, isFocusVisible: false }); + + it('hides the item but keeps it for the description', () => { + expect(selectorChartsFocusedItem(state)).to.equal(null); + expect(selectorChartsKeyboardItem(state)).to.equal(null); + expect(selectorChartsFocusedOrToFocusedItem(state)).to.deep.equal(item); + }); + }); +}); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts index 92a44d68bb990..9c8e64dfe8410 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts @@ -17,10 +17,25 @@ const selectKeyboardNavigation: ChartOptionalRootSelector state.keyboardNavigation; +/** + * The chart owns the DOM focus AND the focus should be rendered. + * A pointer-driven focus is `isFocused` without being visible. + */ +const isFocusVisible = ( + keyboardNavigationState: + | UseChartKeyboardNavigationSignature['state']['keyboardNavigation'] + | undefined, +) => keyboardNavigationState?.isFocused === true && keyboardNavigationState.isFocusVisible === true; + +export const selectorChartsIsFocusVisible = createSelector( + selectKeyboardNavigation, + isFocusVisible, +); + export const selectorChartsItemIsFocused = createSelector( selectKeyboardNavigation, (keyboardNavigationState, item: FocusedItemIdentifier) => - keyboardNavigationState?.isFocused === true && + isFocusVisible(keyboardNavigationState) && keyboardNavigationState?.item != null && fastObjectShallowCompare(keyboardNavigationState.item, item), ); @@ -28,13 +43,13 @@ export const selectorChartsItemIsFocused = createSelector( export const selectorChartsHasFocusedItem = createSelector( selectKeyboardNavigation, (keyboardNavigationState) => - keyboardNavigationState?.isFocused === true && keyboardNavigationState?.item != null, + isFocusVisible(keyboardNavigationState) && keyboardNavigationState?.item != null, ); export const selectorChartsFocusedItem = createSelector( selectKeyboardNavigation, (keyboardNavigationState) => - keyboardNavigationState?.isFocused === true ? (keyboardNavigationState?.item ?? null) : null, + isFocusVisible(keyboardNavigationState) ? (keyboardNavigationState?.item ?? null) : null, ); /** @@ -101,7 +116,7 @@ export const selectorChartsKeyboardYAxisIndex = createSelector( export const selectorChartsKeyboardItem = createSelectorMemoized( selectKeyboardNavigation, function selectorChartsKeyboardItem(keyboardState) { - if (keyboardState?.isFocused !== true || keyboardState?.item == null) { + if (!isFocusVisible(keyboardState) || keyboardState?.item == null) { return null; } const { type, seriesId } = keyboardState.item; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index b4ab19590b566..5de88c551d17c 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -37,6 +37,7 @@ export const useChartKeyboardNavigation: ChartPlugin ({ keyboardNavigation: { item: null, isFocused: false, + isFocusVisible: false, enabled: !params.disableKeyboardNavigation, }, }); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts index ed3069ec9c515..9a920922132d1 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts @@ -17,6 +17,11 @@ export interface UseChartKeyboardNavigationState { * If `false` the focus is ignored, but we keep the item in the state to be able to restore it when focus is active again. */ isFocused: boolean; + /** + * If `false` the focused item is not rendered as focused, and does not drive the highlight and tooltip. + * Set when the focus was moved by a pointer instead of the keyboard. Implies `isFocused`. + */ + isFocusVisible: boolean; /** * Indicates whether keyboard navigation is enabled or not. */ From 526c6738dffec13226993105911d4bdc6b6cab44 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:07:28 +0200 Subject: [PATCH 02/27] [charts] Expose the accessibility proxy element on the chart instance 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. --- .../ChartsAccessibilityProxy.tsx | 31 +++++++++++++++++-- .../useChartElementRef/useChartElementRef.ts | 2 ++ .../useChartElementRef.types.ts | 5 +++ ...eChartKeyboardNavigation.selectors.test.ts | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx index 862066b71682b..9602042d6a276 100644 --- a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx @@ -1,8 +1,30 @@ 'use client'; import * as React from 'react'; +import useForkRef from '@mui/utils/useForkRef'; import { useChartId } from '../../../hooks/useChartId'; +import { useChartsContext } from '../../../context/ChartsProvider'; import { useDescription } from './useDescription'; +/** + * Moves the DOM focus to the chart tab stop. + * The root is only tabbable while there is no description, otherwise one of the announcer + * children holds the tab stop. + * @returns `true` when an element was focused. + */ +export function focusAccessibilityProxy(root: HTMLDivElement | null): boolean { + if (!root) { + return false; + } + + const target = + root.querySelector('[tabindex="0"]') ?? (root.tabIndex >= 0 ? root : null); + + // The proxy covers the whole chart, focusing it would scroll long pages. + target?.focus({ preventScroll: true }); + + return target !== null; +} + /** * Make the proxy looks like a layer. * Having a non-zero size is important for some screen readers to announce the content. @@ -30,12 +52,15 @@ export function ChartsAccessibilityProxy() { const message = useDescription(); const chartId = useChartId(); + const { instance } = useChartsContext(); + const currentFormatRef = React.useRef(null); const currentIndexRef = React.useRef(0); - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); + const handleRef = useForkRef(containerRef, instance.chartsAccessibilityProxyRef); React.useEffect(() => { - const container = containerRef.current as HTMLDivElement | null; + const container = containerRef.current; if (!container) { return; } @@ -111,7 +136,7 @@ export function ChartsAccessibilityProxy() {
); diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.ts index dd8287ab7e9b3..511e4c362c9ab 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.ts @@ -6,11 +6,13 @@ import type { UseChartElementRefSignature } from './useChartElementRef.types'; export const useChartElementRef: ChartPlugin = () => { const chartsLayerContainerRef = React.useRef(null); const chartRootRef = React.useRef(null); + const chartsAccessibilityProxyRef = React.useRef(null); return { instance: { chartsLayerContainerRef, chartRootRef, + chartsAccessibilityProxyRef, }, }; }; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.types.ts index c71cb427415ce..a8987a82db466 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartElementRef/useChartElementRef.types.ts @@ -10,6 +10,11 @@ export interface UseChartElementRefInstance { * Reference to the chart root element. */ chartRootRef: React.RefObject; + /** + * Reference to the root of the accessibility proxy, the element owning the chart tab stop. + * It is `null` when keyboard navigation is disabled. + */ + chartsAccessibilityProxyRef: React.RefObject; } export type UseChartElementRefSignature = ChartPluginSignature<{ diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts index d3795b2abf45c..42983732c0cc9 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.test.ts @@ -21,7 +21,7 @@ const createState = ( enabled: true, ...keyboardNavigation, }, - }) as UseChartKeyboardNavigationState; + }) as any; describe('useChartKeyboardNavigation selectors', () => { describe('when the focus is visible', () => { From df7aa1b78cdf53db82ae5d5d2de536c763b1d3c4 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:09:38 +0200 Subject: [PATCH 03/27] [charts] Add focusItem and focusChart to the keyboard navigation instance `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. --- .../useChartKeyboardNavigation.ts | 188 +++++++++++++++--- .../useChartKeyboardNavigation.types.ts | 36 +++- 2 files changed, 190 insertions(+), 34 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 5de88c551d17c..46799a979338a 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -1,19 +1,128 @@ 'use client'; import * as React from 'react'; import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import useEventCallback from '@mui/utils/useEventCallback'; import { selectorChartDefaultizedSeries } from '../../corePlugins/useChartSeries/useChartSeries.selectors'; import { selectorChartSeriesConfig } from '../../corePlugins/useChartSeriesConfig'; +import { cleanIdentifier } from '../../corePlugins/useChartSeriesConfig/utils/cleanIdentifier'; +import { focusAccessibilityProxy } from '../../../components/ChartsAccessibilityProxy'; import type { ChartPlugin } from '../../models'; -import type { UseChartKeyboardNavigationSignature } from './useChartKeyboardNavigation.types'; +import type { + FocusItemOptions, + UseChartKeyboardNavigationSignature, +} from './useChartKeyboardNavigation.types'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; +import type { FocusedItemIdentifier } from '../../../../models/seriesType'; import type { FocusedItemUpdater } from './keyboardFocusHandler.types'; +/** + * Identifier cleaners always emit every key their series type uses, leaving the missing ones + * `undefined`. A line or area path only knows its series, so it produces a `dataIndex`-less + * identifier that can not be focused. + */ +function isCompleteFocusIdentifier(identifier: object) { + return Object.values(identifier).every((value) => value !== undefined); +} + export const useChartKeyboardNavigation: ChartPlugin = ({ params, store, instance, }) => { - const { chartsLayerContainerRef } = instance; + const { chartsLayerContainerRef, chartsAccessibilityProxyRef } = instance; + + /** + * Whether the next focus change should render the focus. + * Pointer interactions set it to `false` so that clicking an item sets the item keyboard + * navigation resumes from, without revealing the focus indicator. + */ + const focusVisibleIntentRef = React.useRef(true); + + const getDefaultFocusVisible = useEventCallback( + () => params.focusItemOnClick === true || store.state.keyboardNavigation.isFocusVisible, + ); + + /** + * Writes the focus state, only switching the highlight and tooltip to keyboard when visible. + * An `undefined` item leaves the focused item untouched, `null` clears it. + */ + const updateFocus = useEventCallback( + (item: FocusedItemIdentifier | null | undefined, isFocusVisible: boolean) => { + const keyboardNavigation = store.state.keyboardNavigation; + + store.update({ + ...(isFocusVisible && + store.state.highlight && { + highlight: { ...store.state.highlight, lastUpdate: 'keyboard' }, + }), + ...(isFocusVisible && + store.state.interaction && { + interaction: { ...store.state.interaction, lastUpdate: 'keyboard' }, + }), + keyboardNavigation: { + ...keyboardNavigation, + ...(item !== undefined && { item }), + isFocused: true, + isFocusVisible, + }, + }); + }, + ); + + const focusChart = useEventCallback((options?: FocusItemOptions) => { + if (!store.state.keyboardNavigation.enabled) { + return; + } + + const isFocusVisible = options?.visible ?? getDefaultFocusVisible(); + focusVisibleIntentRef.current = isFocusVisible; + + const container = chartsLayerContainerRef.current; + if (!container?.contains(document.activeElement)) { + // Focusing fires `focusin` synchronously, which runs `restoreFocus` with the intent above. + focusAccessibilityProxy(chartsAccessibilityProxyRef.current); + } + + const keyboardNavigation = store.state.keyboardNavigation; + if (keyboardNavigation.isFocused && keyboardNavigation.isFocusVisible === isFocusVisible) { + return; + } + + updateFocus(undefined, isFocusVisible); + }); + + const focusItem = useEventCallback( + (item: FocusedItemIdentifier, options?: FocusItemOptions) => { + if (!store.state.keyboardNavigation.enabled) { + return false; + } + + const seriesConfig = selectorChartSeriesConfig(store.state); + if (!seriesConfig[item.type]?.keyboardFocusHandler) { + // The series type is not part of the keyboard navigation. + return false; + } + + const cleanedItem = cleanIdentifier( + seriesConfig, + item, + ) as FocusedItemIdentifier; + if (!isCompleteFocusIdentifier(cleanedItem)) { + return false; + } + + const isFocusVisible = options?.visible ?? getDefaultFocusVisible(); + focusVisibleIntentRef.current = isFocusVisible; + + const container = chartsLayerContainerRef.current; + if (!container?.contains(document.activeElement)) { + focusAccessibilityProxy(chartsAccessibilityProxyRef.current); + } + + updateFocus(cleanedItem, isFocusVisible); + return true; + }, + ); React.useEffect(() => { const element = chartsLayerContainerRef.current; @@ -33,6 +142,9 @@ export const useChartKeyboardNavigation: ChartPlugin { element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); + element.removeEventListener('pointerdown', trackPointerIntent, true); + element.removeEventListener('click', focusChartOnClick); }; - }, [chartsLayerContainerRef, params.disableKeyboardNavigation, store]); + }, [ + chartsLayerContainerRef, + params.disableKeyboardNavigation, + store, + updateFocus, + getDefaultFocusVisible, + focusChart, + ]); useEnhancedEffect(() => { store.set('keyboardNavigation', { @@ -120,7 +241,7 @@ export const useChartKeyboardNavigation: ChartPlugin ({ @@ -134,4 +255,5 @@ useChartKeyboardNavigation.getInitialState = (params) => ({ useChartKeyboardNavigation.params = { disableKeyboardNavigation: true, + focusItemOnClick: true, }; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts index 9a920922132d1..542b020ccefe3 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts @@ -5,7 +5,34 @@ import type { UseChartHighlightSignature } from '../useChartHighlight'; import type { FocusedItemIdentifier } from '../../../../models/seriesType'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; -export interface UseChartKeyboardNavigationInstance {} +export interface FocusItemOptions { + /** + * Whether the focus indicator should be rendered. + * Defaults to `focusItemOnClick || `. + */ + visible?: boolean; +} + +export interface UseChartKeyboardNavigationInstance { + /** + * Makes an item the one keyboard navigation starts from, moving the DOM focus into the chart + * when it is not already there. + * Does nothing when keyboard navigation is disabled, when the series type does not support it, + * or when the identifier is not complete enough to be focused. + * @param {FocusedItemIdentifier} item The item to focus. + * @param {FocusItemOptions} options Options to override the focus visibility. + * @returns {boolean} `true` when the focus state was updated. + */ + focusItem: ( + item: FocusedItemIdentifier, + options?: FocusItemOptions, + ) => boolean; + /** + * Moves the DOM focus into the chart without changing the focused item. + * @param {FocusItemOptions} options Options to override the focus visibility. + */ + focusChart: (options?: FocusItemOptions) => void; +} export interface UseChartKeyboardNavigationState { keyboardNavigation: { @@ -34,6 +61,13 @@ type UseChartKeyboardNavigationParameters = { * If `true`, disables keyboard navigation for the chart. */ disableKeyboardNavigation?: boolean; + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick?: boolean; }; export type UseChartKeyboardNavigationSignature = ChartPluginSignature<{ From 93e7d23a5c604c1a7e2748a8ad1cc738fafdcd7e Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:11:19 +0200 Subject: [PATCH 04/27] [charts] Add the focusItemOnClick prop Threads the new keyboard navigation param through the container, radar and heatmap prop hooks, and regenerates the propTypes and API documentation. --- docs/pages/x/api/charts/bar-chart-premium.json | 1 + docs/pages/x/api/charts/bar-chart-pro.json | 1 + docs/pages/x/api/charts/bar-chart.json | 1 + docs/pages/x/api/charts/charts-container.json | 1 + docs/pages/x/api/charts/funnel-chart.json | 1 + docs/pages/x/api/charts/heatmap-premium.json | 1 + docs/pages/x/api/charts/heatmap.json | 1 + docs/pages/x/api/charts/line-chart-pro.json | 1 + docs/pages/x/api/charts/line-chart.json | 1 + docs/pages/x/api/charts/pie-chart-pro.json | 1 + docs/pages/x/api/charts/pie-chart.json | 1 + docs/pages/x/api/charts/radar-chart-pro.json | 1 + docs/pages/x/api/charts/radar-chart.json | 1 + docs/pages/x/api/charts/radial-bar-chart.json | 1 + docs/pages/x/api/charts/radial-line-chart.json | 1 + docs/pages/x/api/charts/sankey-chart.json | 1 + docs/pages/x/api/charts/sankey-data-provider.json | 1 + docs/pages/x/api/charts/scatter-chart-premium.json | 1 + docs/pages/x/api/charts/scatter-chart-pro.json | 1 + docs/pages/x/api/charts/scatter-chart.json | 1 + docs/pages/x/api/charts/spark-line-chart.json | 1 + .../charts/bar-chart-premium/bar-chart-premium.json | 3 +++ .../api-docs/charts/bar-chart-pro/bar-chart-pro.json | 3 +++ docs/translations/api-docs/charts/bar-chart/bar-chart.json | 3 +++ .../api-docs/charts/charts-container/charts-container.json | 3 +++ .../api-docs/charts/funnel-chart/funnel-chart.json | 3 +++ .../api-docs/charts/heatmap-premium/heatmap-premium.json | 3 +++ docs/translations/api-docs/charts/heatmap/heatmap.json | 3 +++ .../api-docs/charts/line-chart-pro/line-chart-pro.json | 3 +++ .../api-docs/charts/line-chart/line-chart.json | 3 +++ .../api-docs/charts/pie-chart-pro/pie-chart-pro.json | 3 +++ docs/translations/api-docs/charts/pie-chart/pie-chart.json | 3 +++ .../api-docs/charts/radar-chart-pro/radar-chart-pro.json | 3 +++ .../api-docs/charts/radar-chart/radar-chart.json | 3 +++ .../api-docs/charts/radial-bar-chart/radial-bar-chart.json | 3 +++ .../charts/radial-line-chart/radial-line-chart.json | 3 +++ .../api-docs/charts/sankey-chart/sankey-chart.json | 3 +++ .../charts/sankey-data-provider/sankey-data-provider.json | 3 +++ .../scatter-chart-premium/scatter-chart-premium.json | 3 +++ .../charts/scatter-chart-pro/scatter-chart-pro.json | 3 +++ .../api-docs/charts/scatter-chart/scatter-chart.json | 3 +++ .../api-docs/charts/spark-line-chart/spark-line-chart.json | 3 +++ .../src/BarChartPremium/BarChartPremium.tsx | 7 +++++++ .../x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx | 7 +++++++ .../x-charts-premium/src/RadialBarChart/RadialBarChart.tsx | 7 +++++++ .../src/RadialLineChart/RadialLineChart.tsx | 7 +++++++ .../src/ScatterChartPremium/ScatterChartPremium.tsx | 7 +++++++ packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx | 7 +++++++ packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx | 7 +++++++ packages/x-charts-pro/src/Heatmap/Heatmap.tsx | 7 +++++++ packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts | 2 ++ packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx | 7 +++++++ packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx | 7 +++++++ packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx | 7 +++++++ packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx | 7 +++++++ .../x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx | 7 +++++++ .../x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx | 7 +++++++ packages/x-charts/src/BarChart/BarChart.tsx | 7 +++++++ packages/x-charts/src/ChartsContainer/ChartsContainer.tsx | 7 +++++++ .../src/ChartsContainer/useChartsContainerProps.ts | 2 ++ packages/x-charts/src/LineChart/LineChart.tsx | 7 +++++++ packages/x-charts/src/PieChart/PieChart.tsx | 7 +++++++ packages/x-charts/src/RadarChart/RadarChart.tsx | 7 +++++++ packages/x-charts/src/RadarChart/useRadarChartProps.ts | 2 ++ packages/x-charts/src/ScatterChart/ScatterChart.tsx | 7 +++++++ packages/x-charts/src/SparkLineChart/SparkLineChart.tsx | 7 +++++++ 66 files changed, 237 insertions(+) diff --git a/docs/pages/x/api/charts/bar-chart-premium.json b/docs/pages/x/api/charts/bar-chart-premium.json index 2b442047556cf..8e69384005c6a 100644 --- a/docs/pages/x/api/charts/bar-chart-premium.json +++ b/docs/pages/x/api/charts/bar-chart-premium.json @@ -31,6 +31,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/bar-chart-pro.json b/docs/pages/x/api/charts/bar-chart-pro.json index 2a73d4fb6504c..1b3628f763d23 100644 --- a/docs/pages/x/api/charts/bar-chart-pro.json +++ b/docs/pages/x/api/charts/bar-chart-pro.json @@ -31,6 +31,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/bar-chart.json b/docs/pages/x/api/charts/bar-chart.json index cb7b5f7c73409..67d0d1382719d 100644 --- a/docs/pages/x/api/charts/bar-chart.json +++ b/docs/pages/x/api/charts/bar-chart.json @@ -31,6 +31,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/charts-container.json b/docs/pages/x/api/charts/charts-container.json index b95dd4c944d15..8cda3d37aee09 100644 --- a/docs/pages/x/api/charts/charts-container.json +++ b/docs/pages/x/api/charts/charts-container.json @@ -17,6 +17,7 @@ "disableHitArea": { "type": { "name": "bool" } }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "any" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/funnel-chart.json b/docs/pages/x/api/charts/funnel-chart.json index 0bb1820d76a00..45e293f98cac0 100644 --- a/docs/pages/x/api/charts/funnel-chart.json +++ b/docs/pages/x/api/charts/funnel-chart.json @@ -29,6 +29,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "gap": { "type": { "name": "number" }, "default": "0" }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/heatmap-premium.json b/docs/pages/x/api/charts/heatmap-premium.json index 32cd26dbc60b0..4ebcf20f406bf 100644 --- a/docs/pages/x/api/charts/heatmap-premium.json +++ b/docs/pages/x/api/charts/heatmap-premium.json @@ -28,6 +28,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hideLegend": { "type": { "name": "bool" } }, "highlightedItem": { diff --git a/docs/pages/x/api/charts/heatmap.json b/docs/pages/x/api/charts/heatmap.json index 8d3b9781af6bf..5f4b63178c908 100644 --- a/docs/pages/x/api/charts/heatmap.json +++ b/docs/pages/x/api/charts/heatmap.json @@ -28,6 +28,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hideLegend": { "type": { "name": "bool" } }, "highlightedItem": { diff --git a/docs/pages/x/api/charts/line-chart-pro.json b/docs/pages/x/api/charts/line-chart-pro.json index ec3f9d2a5d932..15820625b7ef5 100644 --- a/docs/pages/x/api/charts/line-chart-pro.json +++ b/docs/pages/x/api/charts/line-chart-pro.json @@ -34,6 +34,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ enablePositionBasedPointerInteraction?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/line-chart.json b/docs/pages/x/api/charts/line-chart.json index 73c2f45a91dc0..4c17a161188ee 100644 --- a/docs/pages/x/api/charts/line-chart.json +++ b/docs/pages/x/api/charts/line-chart.json @@ -34,6 +34,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ enablePositionBasedPointerInteraction?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/pie-chart-pro.json b/docs/pages/x/api/charts/pie-chart-pro.json index 05ee14a2113c7..e28e831ee924b 100644 --- a/docs/pages/x/api/charts/pie-chart-pro.json +++ b/docs/pages/x/api/charts/pie-chart-pro.json @@ -12,6 +12,7 @@ "desc": { "type": { "name": "string" } }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/pie-chart.json b/docs/pages/x/api/charts/pie-chart.json index ad63b7ec9df42..3cacf66c47241 100644 --- a/docs/pages/x/api/charts/pie-chart.json +++ b/docs/pages/x/api/charts/pie-chart.json @@ -12,6 +12,7 @@ "desc": { "type": { "name": "string" } }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radar-chart-pro.json b/docs/pages/x/api/charts/radar-chart-pro.json index 6ccbc492cffa8..15d7806986124 100644 --- a/docs/pages/x/api/charts/radar-chart-pro.json +++ b/docs/pages/x/api/charts/radar-chart-pro.json @@ -21,6 +21,7 @@ "disableKeyboardNavigation": { "type": { "name": "bool" } }, "divisions": { "type": { "name": "number" }, "default": "5" }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radar-chart.json b/docs/pages/x/api/charts/radar-chart.json index a84269d276997..c95f1d30f0a0a 100644 --- a/docs/pages/x/api/charts/radar-chart.json +++ b/docs/pages/x/api/charts/radar-chart.json @@ -21,6 +21,7 @@ "disableKeyboardNavigation": { "type": { "name": "bool" } }, "divisions": { "type": { "name": "number" }, "default": "5" }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radial-bar-chart.json b/docs/pages/x/api/charts/radial-bar-chart.json index bc2ecb8f2e5c0..5e47854b14712 100644 --- a/docs/pages/x/api/charts/radial-bar-chart.json +++ b/docs/pages/x/api/charts/radial-bar-chart.json @@ -23,6 +23,7 @@ "disableAxisListener": { "type": { "name": "bool" }, "default": "false" }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ radius?: bool, rotation?: bool }" } }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/radial-line-chart.json b/docs/pages/x/api/charts/radial-line-chart.json index b73d3e7936da2..0db5f91ef5f91 100644 --- a/docs/pages/x/api/charts/radial-line-chart.json +++ b/docs/pages/x/api/charts/radial-line-chart.json @@ -24,6 +24,7 @@ "disableKeyboardNavigation": { "type": { "name": "bool" } }, "disableLineItemHighlight": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ radius?: bool, rotation?: bool }" } }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/sankey-chart.json b/docs/pages/x/api/charts/sankey-chart.json index dc36562f07178..c4eea23eefeb2 100644 --- a/docs/pages/x/api/charts/sankey-chart.json +++ b/docs/pages/x/api/charts/sankey-chart.json @@ -9,6 +9,7 @@ "desc": { "type": { "name": "string" } }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "highlightedItem": { "type": { diff --git a/docs/pages/x/api/charts/sankey-data-provider.json b/docs/pages/x/api/charts/sankey-data-provider.json index bf30e345f45d3..fb1bfa6e0af60 100644 --- a/docs/pages/x/api/charts/sankey-data-provider.json +++ b/docs/pages/x/api/charts/sankey-data-provider.json @@ -7,6 +7,7 @@ "desc": { "type": { "name": "string" } }, "disableKeyboardNavigation": { "type": { "name": "bool" } }, "experimentalFeatures": { "type": { "name": "object" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "highlightedItem": { "type": { diff --git a/docs/pages/x/api/charts/scatter-chart-premium.json b/docs/pages/x/api/charts/scatter-chart-premium.json index 64539cc3eb55b..d80d510500e25 100644 --- a/docs/pages/x/api/charts/scatter-chart-premium.json +++ b/docs/pages/x/api/charts/scatter-chart-premium.json @@ -46,6 +46,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ progressiveRendering?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/scatter-chart-pro.json b/docs/pages/x/api/charts/scatter-chart-pro.json index d86573978324f..8e4b22f6ded03 100644 --- a/docs/pages/x/api/charts/scatter-chart-pro.json +++ b/docs/pages/x/api/charts/scatter-chart-pro.json @@ -46,6 +46,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ progressiveRendering?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/scatter-chart.json b/docs/pages/x/api/charts/scatter-chart.json index f8b298174aef8..f89102236d020 100644 --- a/docs/pages/x/api/charts/scatter-chart.json +++ b/docs/pages/x/api/charts/scatter-chart.json @@ -46,6 +46,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ progressiveRendering?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/spark-line-chart.json b/docs/pages/x/api/charts/spark-line-chart.json index abff389d0c56c..4e1019fa9c307 100644 --- a/docs/pages/x/api/charts/spark-line-chart.json +++ b/docs/pages/x/api/charts/spark-line-chart.json @@ -41,6 +41,7 @@ "description": "{ enablePositionBasedPointerInteraction?: bool, progressiveRendering?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/translations/api-docs/charts/bar-chart-premium/bar-chart-premium.json b/docs/translations/api-docs/charts/bar-chart-premium/bar-chart-premium.json index 9c07b55f05d9d..c17b3bf78ea38 100644 --- a/docs/translations/api-docs/charts/bar-chart-premium/bar-chart-premium.json +++ b/docs/translations/api-docs/charts/bar-chart-premium/bar-chart-premium.json @@ -26,6 +26,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/bar-chart-pro/bar-chart-pro.json b/docs/translations/api-docs/charts/bar-chart-pro/bar-chart-pro.json index dc322e7111fdc..e730ce94aacbe 100644 --- a/docs/translations/api-docs/charts/bar-chart-pro/bar-chart-pro.json +++ b/docs/translations/api-docs/charts/bar-chart-pro/bar-chart-pro.json @@ -26,6 +26,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/bar-chart/bar-chart.json b/docs/translations/api-docs/charts/bar-chart/bar-chart.json index 5ea3f3708a137..4f8f78a899b45 100644 --- a/docs/translations/api-docs/charts/bar-chart/bar-chart.json +++ b/docs/translations/api-docs/charts/bar-chart/bar-chart.json @@ -26,6 +26,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/charts-container/charts-container.json b/docs/translations/api-docs/charts/charts-container/charts-container.json index 8559de8c28cd1..3fa1648b69eb7 100644 --- a/docs/translations/api-docs/charts/charts-container/charts-container.json +++ b/docs/translations/api-docs/charts/charts-container/charts-container.json @@ -24,6 +24,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/funnel-chart/funnel-chart.json b/docs/translations/api-docs/charts/funnel-chart/funnel-chart.json index f1fab3edf599e..06148cdb517a4 100644 --- a/docs/translations/api-docs/charts/funnel-chart/funnel-chart.json +++ b/docs/translations/api-docs/charts/funnel-chart/funnel-chart.json @@ -19,6 +19,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "gap": { "description": "The gap, in pixels, between funnel sections." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/heatmap-premium/heatmap-premium.json b/docs/translations/api-docs/charts/heatmap-premium/heatmap-premium.json index 1e057d3e26500..ad43769c7a576 100644 --- a/docs/translations/api-docs/charts/heatmap-premium/heatmap-premium.json +++ b/docs/translations/api-docs/charts/heatmap-premium/heatmap-premium.json @@ -19,6 +19,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/heatmap/heatmap.json b/docs/translations/api-docs/charts/heatmap/heatmap.json index f02427622d933..93f941582d5a9 100644 --- a/docs/translations/api-docs/charts/heatmap/heatmap.json +++ b/docs/translations/api-docs/charts/heatmap/heatmap.json @@ -19,6 +19,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/line-chart-pro/line-chart-pro.json b/docs/translations/api-docs/charts/line-chart-pro/line-chart-pro.json index 9538a32bf00db..bf66e949c9126 100644 --- a/docs/translations/api-docs/charts/line-chart-pro/line-chart-pro.json +++ b/docs/translations/api-docs/charts/line-chart-pro/line-chart-pro.json @@ -28,6 +28,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/line-chart/line-chart.json b/docs/translations/api-docs/charts/line-chart/line-chart.json index 91f3129db8f08..35e9357f187d3 100644 --- a/docs/translations/api-docs/charts/line-chart/line-chart.json +++ b/docs/translations/api-docs/charts/line-chart/line-chart.json @@ -28,6 +28,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/pie-chart-pro/pie-chart-pro.json b/docs/translations/api-docs/charts/pie-chart-pro/pie-chart-pro.json index bc1c232672b87..5a8a4b78e0640 100644 --- a/docs/translations/api-docs/charts/pie-chart-pro/pie-chart-pro.json +++ b/docs/translations/api-docs/charts/pie-chart-pro/pie-chart-pro.json @@ -14,6 +14,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/pie-chart/pie-chart.json b/docs/translations/api-docs/charts/pie-chart/pie-chart.json index 08f5e9970c28b..299195ae0e654 100644 --- a/docs/translations/api-docs/charts/pie-chart/pie-chart.json +++ b/docs/translations/api-docs/charts/pie-chart/pie-chart.json @@ -14,6 +14,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/radar-chart-pro/radar-chart-pro.json b/docs/translations/api-docs/charts/radar-chart-pro/radar-chart-pro.json index f72f07f239d05..7b1b04be60929 100644 --- a/docs/translations/api-docs/charts/radar-chart-pro/radar-chart-pro.json +++ b/docs/translations/api-docs/charts/radar-chart-pro/radar-chart-pro.json @@ -16,6 +16,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/radar-chart/radar-chart.json b/docs/translations/api-docs/charts/radar-chart/radar-chart.json index e136c78616a3a..e6ea4c69b06d3 100644 --- a/docs/translations/api-docs/charts/radar-chart/radar-chart.json +++ b/docs/translations/api-docs/charts/radar-chart/radar-chart.json @@ -16,6 +16,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json index 39c606d38dfc2..4810bd8bf79bf 100644 --- a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json +++ b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json @@ -18,6 +18,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a radial grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json index 7e8c656111981..bd55ae63144ce 100644 --- a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json +++ b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json @@ -21,6 +21,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a radial grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/sankey-chart/sankey-chart.json b/docs/translations/api-docs/charts/sankey-chart/sankey-chart.json index 62eac5b16c012..4df864c22f39e 100644 --- a/docs/translations/api-docs/charts/sankey-chart/sankey-chart.json +++ b/docs/translations/api-docs/charts/sankey-chart/sankey-chart.json @@ -12,6 +12,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/sankey-data-provider/sankey-data-provider.json b/docs/translations/api-docs/charts/sankey-data-provider/sankey-data-provider.json index 989e786f564a5..972f2a0ccc573 100644 --- a/docs/translations/api-docs/charts/sankey-data-provider/sankey-data-provider.json +++ b/docs/translations/api-docs/charts/sankey-data-provider/sankey-data-provider.json @@ -11,6 +11,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/docs/translations/api-docs/charts/scatter-chart-premium/scatter-chart-premium.json b/docs/translations/api-docs/charts/scatter-chart-premium/scatter-chart-premium.json index 438111dd12db6..0e4eab61fb406 100644 --- a/docs/translations/api-docs/charts/scatter-chart-premium/scatter-chart-premium.json +++ b/docs/translations/api-docs/charts/scatter-chart-premium/scatter-chart-premium.json @@ -34,6 +34,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/scatter-chart-pro/scatter-chart-pro.json b/docs/translations/api-docs/charts/scatter-chart-pro/scatter-chart-pro.json index 51c44e30651d9..89ef652db7d6e 100644 --- a/docs/translations/api-docs/charts/scatter-chart-pro/scatter-chart-pro.json +++ b/docs/translations/api-docs/charts/scatter-chart-pro/scatter-chart-pro.json @@ -34,6 +34,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/scatter-chart/scatter-chart.json b/docs/translations/api-docs/charts/scatter-chart/scatter-chart.json index b6f1afa9ca72b..6a7673ec38ccf 100644 --- a/docs/translations/api-docs/charts/scatter-chart/scatter-chart.json +++ b/docs/translations/api-docs/charts/scatter-chart/scatter-chart.json @@ -34,6 +34,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "grid": { "description": "Option to display a cartesian grid in the background." }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." diff --git a/docs/translations/api-docs/charts/spark-line-chart/spark-line-chart.json b/docs/translations/api-docs/charts/spark-line-chart/spark-line-chart.json index ca2edd30d8a62..aba5b2928b7dd 100644 --- a/docs/translations/api-docs/charts/spark-line-chart/spark-line-chart.json +++ b/docs/translations/api-docs/charts/spark-line-chart/spark-line-chart.json @@ -34,6 +34,9 @@ "experimentalFeatures": { "description": "Options to enable features planned for the next major." }, + "focusItemOnClick": { + "description": "If true, clicking an item immediately shows the keyboard focus indicator on it. By default, clicking sets the item that keyboard navigation starts from, but the focus indicator stays hidden until the user presses a key." + }, "height": { "description": "The height of the chart in px. If not defined, it takes the height of the parent element." }, diff --git a/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx b/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx index 0896116f7f5f1..31c95cd9dcabb 100644 --- a/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx +++ b/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx @@ -242,6 +242,13 @@ BarChartPremium.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx b/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx index 357b192c6be95..e23b3ca897ad6 100644 --- a/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx +++ b/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx @@ -140,6 +140,13 @@ HeatmapPremium.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx index 3ea961f10caa9..afa4ddd7a6ee1 100644 --- a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx +++ b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx @@ -204,6 +204,13 @@ RadialBarChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a radial grid in the background. */ diff --git a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx index 5833b9c8ac631..7835ba29b1253 100644 --- a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx +++ b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx @@ -221,6 +221,13 @@ RadialLineChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a radial grid in the background. */ diff --git a/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx b/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx index bc5fa6b0de64b..7e9fd50509c21 100644 --- a/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx +++ b/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx @@ -252,6 +252,13 @@ ScatterChartPremium.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ progressiveRendering: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx b/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx index 11213c05b1c09..d4d5da2d3afe1 100644 --- a/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx +++ b/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx @@ -207,6 +207,13 @@ BarChartPro.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx b/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx index 049469dd9ef50..c95c6940b58b7 100644 --- a/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx +++ b/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx @@ -260,6 +260,13 @@ FunnelChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The gap, in pixels, between funnel sections. * @default 0 diff --git a/packages/x-charts-pro/src/Heatmap/Heatmap.tsx b/packages/x-charts-pro/src/Heatmap/Heatmap.tsx index f72139105f533..f795586f0c78a 100644 --- a/packages/x-charts-pro/src/Heatmap/Heatmap.tsx +++ b/packages/x-charts-pro/src/Heatmap/Heatmap.tsx @@ -234,6 +234,13 @@ Heatmap.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts b/packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts index b6637ffcb1ab2..485565115dd3a 100644 --- a/packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts +++ b/packages/x-charts-pro/src/Heatmap/useHeatmapProps.ts @@ -67,6 +67,7 @@ export function useHeatmapProps(props: UseHeatmapProps) { highlightedItem, onHighlightChange, disableKeyboardNavigation, + focusItemOnClick, experimentalFeatures, borderRadius, hideLegend, @@ -141,6 +142,7 @@ export function useHeatmapProps(props: UseHeatmapProps) { highlightedItem, onHighlightChange, disableKeyboardNavigation, + focusItemOnClick, experimentalFeatures, onItemClick, plugins: HEATMAP_PLUGINS, diff --git a/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx b/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx index 16f54913cbada..61e96ba73ab8e 100644 --- a/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx +++ b/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx @@ -223,6 +223,13 @@ LineChartPro.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ enablePositionBasedPointerInteraction: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx b/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx index 03efcd75a55d8..cb358333d0365 100644 --- a/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx +++ b/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx @@ -158,6 +158,13 @@ PieChartPro.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx b/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx index 996a6292e6482..a012d67ff936e 100644 --- a/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx +++ b/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx @@ -156,6 +156,13 @@ RadarChartPro.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx index 8ab3fb85e4520..810bf29ebea3a 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx @@ -117,6 +117,13 @@ SankeyChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx index 58ffff991d538..1c3c8cb4945d2 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx @@ -68,6 +68,13 @@ SankeyDataProvider.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx index 1311667276814..677355927b5ab 100644 --- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx @@ -227,6 +227,13 @@ ScatterChartPro.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ progressiveRendering: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts/src/BarChart/BarChart.tsx b/packages/x-charts/src/BarChart/BarChart.tsx index 1764338393521..947b5084740f5 100644 --- a/packages/x-charts/src/BarChart/BarChart.tsx +++ b/packages/x-charts/src/BarChart/BarChart.tsx @@ -229,6 +229,13 @@ BarChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx b/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx index 89aa496afdfc8..3ef76fefe9c3b 100644 --- a/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx +++ b/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx @@ -124,6 +124,13 @@ ChartsContainer.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.any, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts/src/ChartsContainer/useChartsContainerProps.ts b/packages/x-charts/src/ChartsContainer/useChartsContainerProps.ts index ba3cac1c37cba..c6db09c5bd197 100644 --- a/packages/x-charts/src/ChartsContainer/useChartsContainerProps.ts +++ b/packages/x-charts/src/ChartsContainer/useChartsContainerProps.ts @@ -60,6 +60,7 @@ export const useChartsContainerProps = < slots, slotProps, disableKeyboardNavigation, + focusItemOnClick, brushConfig, onHiddenItemsChange, hiddenItems, @@ -105,6 +106,7 @@ export const useChartsContainerProps = < seriesConfig, experimentalFeatures, disableKeyboardNavigation, + focusItemOnClick, brushConfig, onHiddenItemsChange, hiddenItems, diff --git a/packages/x-charts/src/LineChart/LineChart.tsx b/packages/x-charts/src/LineChart/LineChart.tsx index b6c4fec7aa3b0..1bcc1fd05ea7c 100644 --- a/packages/x-charts/src/LineChart/LineChart.tsx +++ b/packages/x-charts/src/LineChart/LineChart.tsx @@ -262,6 +262,13 @@ LineChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ enablePositionBasedPointerInteraction: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts/src/PieChart/PieChart.tsx b/packages/x-charts/src/PieChart/PieChart.tsx index 94bd49752c083..92c6c800350c7 100644 --- a/packages/x-charts/src/PieChart/PieChart.tsx +++ b/packages/x-charts/src/PieChart/PieChart.tsx @@ -205,6 +205,13 @@ PieChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts/src/RadarChart/RadarChart.tsx b/packages/x-charts/src/RadarChart/RadarChart.tsx index 6dbef3fe61722..6572e63304766 100644 --- a/packages/x-charts/src/RadarChart/RadarChart.tsx +++ b/packages/x-charts/src/RadarChart/RadarChart.tsx @@ -169,6 +169,13 @@ RadarChart.propTypes /* remove-proptypes */ = { * Options to enable features planned for the next major. */ experimentalFeatures: PropTypes.object, + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ diff --git a/packages/x-charts/src/RadarChart/useRadarChartProps.ts b/packages/x-charts/src/RadarChart/useRadarChartProps.ts index e65d624e1aa14..d0026e4d10fc5 100644 --- a/packages/x-charts/src/RadarChart/useRadarChartProps.ts +++ b/packages/x-charts/src/RadarChart/useRadarChartProps.ts @@ -44,6 +44,7 @@ export const useRadarChartProps = (props: RadarChartProps) => { onAreaClick, onMarkClick, disableKeyboardNavigation, + focusItemOnClick, experimentalFeatures, className, ...other @@ -63,6 +64,7 @@ export const useRadarChartProps = (props: RadarChartProps) => { skipAnimation, onAxisClick, disableKeyboardNavigation, + focusItemOnClick, experimentalFeatures, plugins: RADAR_PLUGINS, }; diff --git a/packages/x-charts/src/ScatterChart/ScatterChart.tsx b/packages/x-charts/src/ScatterChart/ScatterChart.tsx index 5c40f27ce2909..761f9d5327bee 100644 --- a/packages/x-charts/src/ScatterChart/ScatterChart.tsx +++ b/packages/x-charts/src/ScatterChart/ScatterChart.tsx @@ -270,6 +270,13 @@ ScatterChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ progressiveRendering: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * Option to display a cartesian grid in the background. */ diff --git a/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx b/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx index 1d00fe2f2cb26..57a574d65359d 100644 --- a/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx +++ b/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx @@ -433,6 +433,13 @@ SparkLineChart.propTypes /* remove-proptypes */ = { enablePositionBasedPointerInteraction: PropTypes.bool, progressiveRendering: PropTypes.bool, }), + /** + * If `true`, clicking an item immediately shows the keyboard focus indicator on it. + * By default, clicking sets the item that keyboard navigation starts from, but the focus + * indicator stays hidden until the user presses a key. + * @default false + */ + focusItemOnClick: PropTypes.bool, /** * The height of the chart in px. If not defined, it takes the height of the parent element. */ From 14340370a8d14214bde943e6a5ad05c7f132a2b1 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:27:18 +0200 Subject: [PATCH 05/27] [charts] Focus the clicked item on every keyboard navigable series 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. --- docs/pages/x/api/charts/radial-bar-chart.json | 7 ++ .../pages/x/api/charts/radial-line-chart.json | 7 ++ .../radial-bar-chart/radial-bar-chart.json | 7 ++ .../radial-line-chart/radial-line-chart.json | 7 ++ .../BarChartPremium/RangeBar/RangeBarPlot.tsx | 15 ++-- .../x-charts-premium/src/Map/MapShape.tsx | 3 +- .../RadialBarChart/RadialBarChart.plugins.ts | 4 ++ .../src/RadialBarChart/RadialBarChart.tsx | 7 ++ .../RadialLineChart.plugins.ts | 4 ++ .../src/RadialLineChart/RadialLineChart.tsx | 7 ++ .../src/FunnelChart/FunnelSection.tsx | 3 +- .../src/SankeyChart/SankeyLinkElement.tsx | 7 +- .../src/SankeyChart/SankeyNodeElement.tsx | 7 +- .../BarChart/useRegisterItemClickHandlers.ts | 18 +++-- .../x-charts/src/LineChart/AreaElement.tsx | 3 +- packages/x-charts/src/LineChart/AreaPlot.tsx | 3 +- .../src/LineChart/CircleMarkElement.tsx | 10 ++- .../x-charts/src/LineChart/LineElement.tsx | 3 +- packages/x-charts/src/LineChart/LinePlot.tsx | 3 +- .../x-charts/src/LineChart/MarkElement.tsx | 10 ++- .../src/LineChart/useLineItemClickHandler.ts | 21 +++--- packages/x-charts/src/PieChart/PieArc.tsx | 6 +- .../RadarSeriesPlot/RadarSeriesArea.tsx | 18 +++-- .../RadarSeriesPlot/RadarSeriesMarks.tsx | 11 ++- .../useInteractionAllItemProps.ts | 8 ++- .../x-charts/src/ScatterChart/Scatter.tsx | 20 +++--- .../ScatterChart/async/ScatterAsyncBatch.tsx | 20 +++--- .../src/hooks/useInteractionItemProps.ts | 68 ++++++++++++------- .../ChartsAccessibilityProxy.tsx | 20 ------ .../focusAccessibilityProxy.ts | 20 ++++++ .../ChartsAccessibilityProxy/index.ts | 1 + .../useChartClosestPoint.ts | 8 ++- .../useChartClosestPoint.types.ts | 2 + .../useChartItemClick/useChartItemClick.ts | 12 ++-- .../useChartItemClick.types.ts | 2 + .../useChartKeyboardNavigation.selectors.ts | 3 +- .../useChartKeyboardNavigation.ts | 14 +++- .../useChartKeyboardNavigation.types.ts | 5 +- 38 files changed, 262 insertions(+), 132 deletions(-) create mode 100644 packages/x-charts/src/internals/components/ChartsAccessibilityProxy/focusAccessibilityProxy.ts diff --git a/docs/pages/x/api/charts/radial-bar-chart.json b/docs/pages/x/api/charts/radial-bar-chart.json index 5e47854b14712..0aae81651ddd5 100644 --- a/docs/pages/x/api/charts/radial-bar-chart.json +++ b/docs/pages/x/api/charts/radial-bar-chart.json @@ -75,6 +75,13 @@ "describedArgs": ["highlightedItem"] } }, + "onItemClick": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.MouseEvent, item: SeriesItemIdentifierWithType) => void", + "describedArgs": ["event", "item"] + } + }, "onTooltipItemChange": { "type": { "name": "func" }, "signature": { diff --git a/docs/pages/x/api/charts/radial-line-chart.json b/docs/pages/x/api/charts/radial-line-chart.json index 0db5f91ef5f91..8557d69c95cdd 100644 --- a/docs/pages/x/api/charts/radial-line-chart.json +++ b/docs/pages/x/api/charts/radial-line-chart.json @@ -76,6 +76,13 @@ "describedArgs": ["highlightedItem"] } }, + "onItemClick": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.MouseEvent, item: SeriesItemIdentifierWithType) => void", + "describedArgs": ["event", "item"] + } + }, "onTooltipItemChange": { "type": { "name": "func" }, "signature": { diff --git a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json index 4810bd8bf79bf..c11593cbc93e8 100644 --- a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json +++ b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json @@ -74,6 +74,13 @@ } } }, + "onItemClick": { + "description": "The callback fired when an item is clicked.", + "typeDescriptions": { + "event": { "name": "event", "description": "The click event." }, + "item": { "name": "item", "description": "The clicked item." } + } + }, "onTooltipItemChange": { "description": "The callback fired when the tooltip item changes.", "typeDescriptions": { diff --git a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json index bd55ae63144ce..9be6ef7913ca5 100644 --- a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json +++ b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json @@ -77,6 +77,13 @@ } } }, + "onItemClick": { + "description": "The callback fired when an item is clicked.", + "typeDescriptions": { + "event": { "name": "event", "description": "The click event." }, + "item": { "name": "item", "description": "The clicked item." } + } + }, "onTooltipItemChange": { "description": "The callback fired when the tooltip item changes.", "typeDescriptions": { diff --git a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx index d8761ee806f11..8dba37420420b 100644 --- a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx +++ b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; -import { useSkipAnimation } from '@mui/x-charts/internals'; +import { useSkipAnimation, useChartsContext } from '@mui/x-charts/internals'; +import type { UseChartKeyboardNavigationSignature } from '@mui/x-charts/internals'; import { BarElement, barClasses } from '@mui/x-charts/BarChart'; import type { BarElementSlotProps, @@ -99,6 +100,7 @@ function RangeBarSvgPlot(props: Omit): React.JSX. const completedData = useRangeBarPlotData(useDrawingArea(), xAxes, yAxes); const classes = useUtilityClasses(); + const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); const slots: BarElementSlots = { ...props.slots, bar: props.slots?.bar ?? AnimatedRangeBarElement, @@ -129,12 +131,11 @@ function RangeBarSvgPlot(props: Omit): React.JSX. ry={borderRadius} {...other} slots={slots} - onClick={ - onItemClick && - ((event) => { - onItemClick(event, { type: 'rangeBar', seriesId, dataIndex }); - }) - } + onClick={(event) => { + const identifier = { type: 'rangeBar', seriesId, dataIndex } as const; + instance.focusItem?.(identifier); + onItemClick?.(event, identifier); + }} /> ); })} diff --git a/packages/x-charts-premium/src/Map/MapShape.tsx b/packages/x-charts-premium/src/Map/MapShape.tsx index dc0f1f8148ef4..e5ef0eb7f2a10 100644 --- a/packages/x-charts-premium/src/Map/MapShape.tsx +++ b/packages/x-charts-premium/src/Map/MapShape.tsx @@ -29,7 +29,7 @@ function MapShape(props: MapShapeProps) { () => ({ type: 'mapShape' as const, seriesId, name: featureName }), [seriesId, featureName], ); - const interactionProps = useInteractionItemProps(identifier); + const interactionProps = useInteractionItemProps(identifier, { onClick }); const highlightState = useItemHighlightState(identifier); const isHighlighted = highlightState === 'highlighted'; const isFaded = highlightState === 'faded'; @@ -38,7 +38,6 @@ function MapShape(props: MapShapeProps) { , UseChartVisibilityManagerSignature<'radialBar'>, UseChartKeyboardNavigationSignature, + UseChartItemClickSignature<'radialBar'>, UseChartProExportSignature, ]; @@ -40,5 +43,6 @@ export const RADIAL_BAR_CHART_PLUGINS: ConvertSignaturesIntoPlugins | null} highlightedItem The newly highlighted item. */ onHighlightChange: PropTypes.func, + /** + * The callback fired when an item is clicked. + * + * @param {React.MouseEvent} event The click event. + * @param {SeriesItemIdentifierWithType} item The clicked item. + */ + onItemClick: PropTypes.func, /** * The callback fired when the tooltip item changes. * diff --git a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts index 55a6dc1dd539d..ad3677f3a41f6 100644 --- a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts +++ b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts @@ -5,6 +5,7 @@ import { useChartInteraction, useChartHighlight, useChartKeyboardNavigation, + useChartItemClick, useChartVisibilityManager, } from '@mui/x-charts/internals'; import type { @@ -14,6 +15,7 @@ import type { UseChartInteractionSignature, UseChartHighlightSignature, UseChartKeyboardNavigationSignature, + UseChartItemClickSignature, UseChartVisibilityManagerSignature, ConvertSignaturesIntoPlugins, } from '@mui/x-charts/internals'; @@ -28,6 +30,7 @@ export type RadialLineChartPluginSignatures = [ UseChartHighlightSignature<'radialLine'>, UseChartVisibilityManagerSignature<'radialLine'>, UseChartKeyboardNavigationSignature, + UseChartItemClickSignature<'radialLine'>, UseChartProExportSignature, ]; @@ -40,5 +43,6 @@ export const RADIAL_LINE_CHART_PLUGINS: ConvertSignaturesIntoPlugins | null} highlightedItem The newly highlighted item. */ onHighlightChange: PropTypes.func, + /** + * The callback fired when an item is clicked. + * + * @param {React.MouseEvent} event The click event. + * @param {SeriesItemIdentifierWithType} item The clicked item. + */ + onItemClick: PropTypes.func, /** * The callback fired when the tooltip item changes. * diff --git a/packages/x-charts-pro/src/FunnelChart/FunnelSection.tsx b/packages/x-charts-pro/src/FunnelChart/FunnelSection.tsx index 96abd8ba90196..1cc43beb0bda6 100644 --- a/packages/x-charts-pro/src/FunnelChart/FunnelSection.tsx +++ b/packages/x-charts-pro/src/FunnelChart/FunnelSection.tsx @@ -53,7 +53,7 @@ const FunnelSection = consumeSlots( [seriesId, dataIndex], ); - const interactionProps = useInteractionItemProps(identifier); + const interactionProps = useInteractionItemProps(identifier, { onClick }); const highlightState = useItemHighlightState(identifier); const isHighlighted = highlightState === 'highlighted'; const isFaded = highlightState === 'faded'; @@ -71,7 +71,6 @@ const FunnelSection = consumeSlots( strokeOpacity={1} strokeWidth={isOutlined ? 1.5 : 0} cursor={onClick ? 'pointer' : 'unset'} - onClick={onClick} data-highlighted={isHighlighted || undefined} data-faded={isFaded || undefined} className={clsx(classes?.section, className)} diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx index 42e0dae628642..1331d9f0f0211 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx @@ -51,15 +51,15 @@ export const SankeyLinkElement = React.forwardRef) => { onClick?.(event, identifier); }); + // Add interaction props for tooltips and keyboard focus + const interactionProps = useInteractionItemProps(identifier, { onClick: handleClick }); + if (!link.path) { return null; } @@ -82,7 +82,6 @@ export const SankeyLinkElement = React.forwardRef diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx index 1253212245af2..54bb755b92277 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx @@ -54,15 +54,15 @@ export const SankeyNodeElement = React.forwardRef) => { onClick?.(event, identifier); }); + // Add interaction props for tooltips and keyboard focus + const interactionProps = useInteractionItemProps(identifier, { onClick: handleClick }); + let opacity = 1; if (isFaded) { opacity = 0.3; @@ -78,7 +78,6 @@ export const SankeyNodeElement = React.forwardRef, UseChartTooltipSignature] + [ + UseChartInteractionSignature, + UseChartHighlightSignature<'bar'>, + UseChartTooltipSignature, + UseChartKeyboardNavigationSignature, + ] >(); const chartsLayerContainerRef = useChartsLayerContainerRef(); const store = useStore<[UseChartCartesianAxisSignature, UseChartHighlightSignature<'bar'>]>(); @@ -28,7 +34,8 @@ export function useRegisterItemClickHandlers( React.useEffect(() => { const element = chartsLayerContainerRef.current; - if (!element || !onItemClick) { + // The handlers also feed the keyboard navigation, so they are registered without `onItemClick`. + if (!element) { return undefined; } @@ -66,11 +73,14 @@ export function useRegisterItemClickHandlers( const item = selectorBarItemAtPosition(store.state, svgPoint); if (item) { - onItemClick(event, { + const identifier = { type: 'bar', seriesId: item.seriesId, dataIndex: item.dataIndex, - }); + } as const; + + instance.focusItem?.(identifier); + onItemClick?.(event, identifier); } }; diff --git a/packages/x-charts/src/LineChart/AreaElement.tsx b/packages/x-charts/src/LineChart/AreaElement.tsx index 407ae5df8687d..8c93a304fe600 100644 --- a/packages/x-charts/src/LineChart/AreaElement.tsx +++ b/packages/x-charts/src/LineChart/AreaElement.tsx @@ -72,6 +72,7 @@ function AreaElement(props: AreaElementProps) { slots, slotProps, onClick, + cursor, ...other } = props; @@ -103,7 +104,7 @@ function AreaElement(props: AreaElementProps) { additionalProps: { ...(enablePositionBasedPointerInteraction ? {} : interactionProps), onClick, - cursor: onClick ? 'pointer' : 'unset', + cursor: cursor ?? (onClick ? 'pointer' : 'unset'), 'data-highlighted': isHighlighted || undefined, 'data-faded': isFaded || undefined, 'data-series-id': seriesId, diff --git a/packages/x-charts/src/LineChart/AreaPlot.tsx b/packages/x-charts/src/LineChart/AreaPlot.tsx index 307035f51e2be..321c9cae3b588 100644 --- a/packages/x-charts/src/LineChart/AreaPlot.tsx +++ b/packages/x-charts/src/LineChart/AreaPlot.tsx @@ -91,7 +91,8 @@ function AreaPlot(props: AreaPlotProps) { gradientId={gradientId} slots={slots} slotProps={slotProps} - onClick={onAreaItemClick && ((event) => onAreaItemClick(event, seriesId))} + onClick={(event) => onAreaItemClick(event, seriesId)} + cursor={onItemClick ? 'pointer' : 'unset'} skipAnimation={skipAnimation} /> ), diff --git a/packages/x-charts/src/LineChart/CircleMarkElement.tsx b/packages/x-charts/src/LineChart/CircleMarkElement.tsx index 8dd4968dd37c0..52af81588e1fb 100644 --- a/packages/x-charts/src/LineChart/CircleMarkElement.tsx +++ b/packages/x-charts/src/LineChart/CircleMarkElement.tsx @@ -83,7 +83,11 @@ function CircleMarkElement(props: CircleMarkElementProps) { selectorChartExperimentalFeaturesState, 'enablePositionBasedPointerInteraction', ); - const interactionProps = useInteractionItemProps({ type: 'line', seriesId, dataIndex }); + // The click keeps focusing the item even when the pointer interactions are position based. + const { onClick: handleClick, ...pointerProps } = useInteractionItemProps( + { type: 'line', seriesId, dataIndex }, + { onClick }, + ); const theme = useTheme(); const classes = useLineUtilityClasses({ skipAnimation, classes: innerClasses }); @@ -91,7 +95,7 @@ function CircleMarkElement(props: CircleMarkElementProps) { return ( onLineItemClick(event, seriesId))} + onClick={(event) => onLineItemClick(event, seriesId)} + cursor={onItemClick ? 'pointer' : 'unset'} /> ); })} diff --git a/packages/x-charts/src/LineChart/MarkElement.tsx b/packages/x-charts/src/LineChart/MarkElement.tsx index 486ab6be58450..79544848ff688 100644 --- a/packages/x-charts/src/LineChart/MarkElement.tsx +++ b/packages/x-charts/src/LineChart/MarkElement.tsx @@ -97,7 +97,11 @@ function MarkElement(props: MarkElementProps) { selectorChartExperimentalFeaturesState, 'enablePositionBasedPointerInteraction', ); - const interactionProps = useInteractionItemProps({ type: 'line', seriesId, dataIndex }); + // The click keeps focusing the item even when the pointer interactions are position based. + const { onClick: handleClick, ...pointerProps } = useInteractionItemProps( + { type: 'line', seriesId, dataIndex }, + { onClick }, + ); const ownerState = { seriesId, @@ -111,7 +115,7 @@ function MarkElement(props: MarkElementProps) { return ( , lineItemIdentifier: LineItemClickIdentifier, ) => void, -): ((event: React.MouseEvent, seriesId: SeriesId) => void) | undefined { +): (event: React.MouseEvent, seriesId: SeriesId) => void { const chartsLayerContainerRef = useChartsLayerContainerRef(); const { xAxis: xAxes, xAxisIds } = useXAxes(); const seriesData = useLineSeriesContext(); const defaultXAxisId = xAxisIds[0]; + const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); return React.useMemo(() => { - if (!onItemClick) { - return undefined; - } - return (event: React.MouseEvent, seriesId: SeriesId) => { const element = chartsLayerContainerRef.current; const xAxisId = seriesData?.series[seriesId]?.xAxisId ?? defaultXAxisId; @@ -47,7 +47,10 @@ export function useLineItemClickHandler( return; } - onItemClick(event, { type: 'line', seriesId, dataIndex }); + const identifier = { type: 'line', seriesId, dataIndex } as const; + + instance.focusItem?.(identifier); + onItemClick?.(event, identifier); }; - }, [onItemClick, chartsLayerContainerRef, seriesData, defaultXAxisId, xAxes]); + }, [onItemClick, chartsLayerContainerRef, seriesData, defaultXAxisId, xAxes, instance]); } diff --git a/packages/x-charts/src/PieChart/PieArc.tsx b/packages/x-charts/src/PieChart/PieArc.tsx index 3008ef060d460..a7d910c2f8cb3 100644 --- a/packages/x-charts/src/PieChart/PieArc.tsx +++ b/packages/x-charts/src/PieChart/PieArc.tsx @@ -75,7 +75,10 @@ const PieArc = React.forwardRef(function PieArc(pro }; const classes = usePieUtilityClasses(ownerState); - const interactionProps = useInteractionItemProps({ type: 'pie', seriesId, dataIndex }); + const interactionProps = useInteractionItemProps( + { type: 'pie', seriesId, dataIndex }, + { onClick }, + ); const animatedProps = useAnimatePieArc({ cornerRadius, startAngle, @@ -89,7 +92,6 @@ const PieArc = React.forwardRef(function PieArc(pro return ( (); + const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); const classes = useUtilityClasses(inClasses); return ( @@ -67,15 +70,18 @@ function RadarSeriesArea(props: RadarSeriesAreaProps) { getHighlightState, classes, })} - onClick={(event) => - onItemClick?.(event, { + cursor={onItemClick ? 'pointer' : 'unset'} + {...interactionProps[seriesIndex]} + // The area only knows its series, the data index comes from the click angle. + onClick={(event) => { + const identifier = { type: 'radar', seriesId: id, dataIndex: getRotationIndex(event), - }) - } - cursor={onItemClick ? 'pointer' : 'unset'} - {...interactionProps[seriesIndex]} + } as const; + instance.focusItem?.(identifier); + onItemClick?.(event, identifier); + }} {...other} /> ); diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx index a64ac26555e16..4d8d38344baf8 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx @@ -8,6 +8,8 @@ import { useItemHighlightStateGetter } from '../../hooks/useItemHighlightStateGe import type { SeriesId } from '../../models/seriesType/common'; import type { HighlightItemIdentifierWithType } from '../../models'; import type { HighlightState } from '../../hooks/useItemHighlightState'; +import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; +import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; interface GetCirclePropsParams { seriesId: SeriesId; @@ -43,6 +45,7 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { const classes = useUtilityClasses(inClasses); const getHighlightState = useItemHighlightStateGetter(); + const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); return ( @@ -65,9 +68,11 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { classes, })} 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} /> diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts b/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts index 5c15068920f4a..08316c17eabfc 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import type { SeriesItemIdentifierWithData } from '../../models/seriesType'; import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; import type { UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight'; +import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartTooltipSignature } from '../../internals/plugins/featurePlugins/useChartTooltip'; import { getInteractionItemProps } from '../../hooks/useInteractionItemProps'; @@ -12,7 +13,12 @@ export const useInteractionAllItemProps = ( ) => { const { instance } = useChartsContext< - [UseChartInteractionSignature, UseChartHighlightSignature<'radar'>, UseChartTooltipSignature] + [ + UseChartInteractionSignature, + UseChartHighlightSignature<'radar'>, + UseChartTooltipSignature, + UseChartKeyboardNavigationSignature, + ] >(); const results = React.useMemo(() => { diff --git a/packages/x-charts/src/ScatterChart/Scatter.tsx b/packages/x-charts/src/ScatterChart/Scatter.tsx index 3e289eb49526f..6e7c4c758bf3c 100644 --- a/packages/x-charts/src/ScatterChart/Scatter.tsx +++ b/packages/x-charts/src/ScatterChart/Scatter.tsx @@ -24,6 +24,7 @@ import { useChartsContext } from '../context/ChartsProvider'; import type { UseChartTooltipSignature } from '../internals/plugins/featurePlugins/useChartTooltip'; import type { UseChartInteractionSignature } from '../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartHighlightSignature } from '../internals/plugins/featurePlugins/useChartHighlight'; +import type { UseChartKeyboardNavigationSignature } from '../internals/plugins/featurePlugins/useChartKeyboardNavigation'; /** * @deprecated The `Scatter` component is an internal implementation detail of `ScatterPlot` and will be removed from the public API in v10. Use `ScatterPlot` instead. @@ -98,6 +99,7 @@ function Scatter(props: ScatterProps) { UseChartInteractionSignature, UseChartHighlightSignature<'scatter'>, UseChartTooltipSignature, + UseChartKeyboardNavigationSignature, ] >(); const store = useStore<[UseChartClosestPointSignature]>(); @@ -138,20 +140,18 @@ function Scatter(props: ScatterProps) { isFaded={isItemFaded} x={dataPoint.x} y={dataPoint.y} - onClick={ - onItemClick && - ((event) => - onItemClick(event, { - type: 'scatter', - seriesId: series.id, - dataIndex: dataPoint.dataIndex, - })) - } data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} {...(skipInteractionHandlers ? undefined - : getInteractionItemProps(instance, dataPoint))} + : getInteractionItemProps(instance, dataPoint, { + onClick: (event) => + onItemClick?.(event, { + type: 'scatter', + seriesId: series.id, + dataIndex: dataPoint.dataIndex, + }), + }))} {...markerProps} /> ); diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index 285ecdfaa9bd9..184414f6a4daf 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -15,6 +15,7 @@ import { useChartsContext } from '../../context/ChartsProvider'; import type { UseChartTooltipSignature } from '../../internals/plugins/featurePlugins/useChartTooltip'; import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight'; +import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; import type { ScatterProps } from '../Scatter'; import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; @@ -66,6 +67,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { UseChartInteractionSignature, UseChartHighlightSignature<'scatter'>, UseChartTooltipSignature, + UseChartKeyboardNavigationSignature, ] >(); const store = useStore<[UseChartClosestPointSignature]>(); @@ -117,20 +119,18 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { isFaded={isItemFaded} x={x} y={y} - onClick={ - onItemClick && - ((event: React.MouseEvent) => - onItemClick(event, { - type: 'scatter', - seriesId: series.id, - dataIndex, - })) - } data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} {...(skipInteractionHandlers || isInteracting ? undefined - : getInteractionItemProps(instance, dataPoint))} + : getInteractionItemProps(instance, dataPoint, { + onClick: (event) => + onItemClick?.(event, { + type: 'scatter', + seriesId: series.id, + dataIndex, + }), + }))} {...markerProps} />, ); diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 865c48aae27ff..9a0d3ece4e4f0 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -5,9 +5,32 @@ import { useChartsContext } from '../context/ChartsProvider'; import type { UseChartHighlightSignature } from '../internals/plugins/featurePlugins/useChartHighlight'; import type { UseChartInteractionSignature } from '../internals/plugins/featurePlugins/useChartInteraction'; import type { ChartSeriesType } from '../models/seriesType/config'; -import type { SeriesItemIdentifierWithType } from '../models/seriesType'; +import type { FocusedItemIdentifier, SeriesItemIdentifierWithType } from '../models/seriesType'; import type { ChartInstance } from '../internals/plugins/models'; import type { UseChartTooltipSignature } from '../internals/plugins/featurePlugins/useChartTooltip'; +import type { UseChartKeyboardNavigationSignature } from '../internals/plugins/featurePlugins/useChartKeyboardNavigation'; + +type InteractionItemSignatures = [ + UseChartInteractionSignature, + UseChartHighlightSignature, + UseChartTooltipSignature, + UseChartKeyboardNavigationSignature, +]; + +export interface InteractionItemOptions { + /** + * Click handler of the consumer, called after the item took the keyboard focus. + * @param {React.MouseEvent} event The click event. + */ + onClick?: (event: React.MouseEvent) => void; +} + +export interface InteractionItemProps { + onPointerEnter?: () => void; + onPointerLeave?: () => void; + onPointerDown?: (event: React.PointerEvent) => void; + onClick?: (event: React.MouseEvent) => void; +} function onPointerDown(event: React.PointerEvent) { if ( @@ -20,19 +43,9 @@ function onPointerDown(event: React.PointerEvent) { export const useInteractionItemProps = ( data: SeriesItemIdentifierWithType, -): { - onPointerEnter?: () => void; - onPointerLeave?: () => void; - onPointerDown?: (event: React.PointerEvent) => void; -} => { - const { instance } = - useChartsContext< - [ - UseChartInteractionSignature, - UseChartHighlightSignature, - UseChartTooltipSignature, - ] - >(); + options?: InteractionItemOptions, +): InteractionItemProps => { + const { instance } = useChartsContext>(); const interactionActive = React.useRef(false); const onPointerEnter = useEventCallback(() => { interactionActive.current = true; @@ -47,6 +60,11 @@ export const useInteractionItemProps = ( instance.clearHighlight(); }); + const onClick = useEventCallback((event: React.MouseEvent) => { + instance.focusItem?.(data as unknown as FocusedItemIdentifier); + options?.onClick?.(event); + }); + React.useEffect(() => { return () => { /* Clean up state if this item is unmounted while active. */ @@ -61,21 +79,17 @@ export const useInteractionItemProps = ( onPointerEnter, onPointerLeave, onPointerDown, + onClick, }), - [onPointerEnter, onPointerLeave], + [onPointerEnter, onPointerLeave, onClick], ); }; export function getInteractionItemProps( - instance: ChartInstance< - [UseChartInteractionSignature, UseChartHighlightSignature, UseChartTooltipSignature] - >, + instance: ChartInstance>, item: SeriesItemIdentifierWithType, -): { - onPointerEnter?: () => void; - onPointerLeave?: () => void; - onPointerDown?: (event: React.PointerEvent) => void; -} { + options?: InteractionItemOptions, +): InteractionItemProps { function onPointerEnter() { if (!item) { return; @@ -93,9 +107,17 @@ export function getInteractionItemProps( instance.clearHighlight(); } + function onClick(event: React.MouseEvent) { + if (item) { + instance.focusItem?.(item as unknown as FocusedItemIdentifier); + } + options?.onClick?.(event); + } + return { onPointerEnter, onPointerLeave, onPointerDown, + onClick, }; } diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx index 9602042d6a276..da08044fb5380 100644 --- a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx @@ -5,26 +5,6 @@ import { useChartId } from '../../../hooks/useChartId'; import { useChartsContext } from '../../../context/ChartsProvider'; import { useDescription } from './useDescription'; -/** - * Moves the DOM focus to the chart tab stop. - * The root is only tabbable while there is no description, otherwise one of the announcer - * children holds the tab stop. - * @returns `true` when an element was focused. - */ -export function focusAccessibilityProxy(root: HTMLDivElement | null): boolean { - if (!root) { - return false; - } - - const target = - root.querySelector('[tabindex="0"]') ?? (root.tabIndex >= 0 ? root : null); - - // The proxy covers the whole chart, focusing it would scroll long pages. - target?.focus({ preventScroll: true }); - - return target !== null; -} - /** * Make the proxy looks like a layer. * Having a non-zero size is important for some screen readers to announce the content. diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/focusAccessibilityProxy.ts b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/focusAccessibilityProxy.ts new file mode 100644 index 0000000000000..766b89e19a034 --- /dev/null +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/focusAccessibilityProxy.ts @@ -0,0 +1,20 @@ +/** + * Moves the DOM focus to the chart tab stop. + * The proxy root is only tabbable while there is no description, otherwise one of the announcer + * children holds the tab stop. + * @param {HTMLDivElement | null} root The accessibility proxy root. + * @returns {boolean} `true` when an element was focused. + */ +export function focusAccessibilityProxy(root: HTMLDivElement | null): boolean { + if (!root) { + return false; + } + + const target = + root.querySelector('[tabindex="0"]') ?? (root.tabIndex >= 0 ? root : null); + + // The proxy covers the whole chart, focusing it would scroll long pages. + target?.focus({ preventScroll: true }); + + return target !== null; +} diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/index.ts b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/index.ts index f8598a15eac93..ff8f31eac5d54 100644 --- a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/index.ts +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/index.ts @@ -1 +1,2 @@ export * from './ChartsAccessibilityProxy'; +export * from './focusAccessibilityProxy'; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts index 20b8fbf3789d6..6c83755612f3f 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts @@ -224,9 +224,13 @@ export const useChartClosestPoint: ChartPlugin = const tapHandler = instance.addInteractionListener('tap', (event) => { const closestPoint = getClosestPoint(event.detail.srcEvent); - if (typeof closestPoint !== 'string' && onItemClick) { + if (typeof closestPoint !== 'string') { const { seriesId, dataIndex } = closestPoint; - onItemClick(event.detail.srcEvent, { type: 'scatter', seriesId, dataIndex }); + const identifier = { type: 'scatter', seriesId, dataIndex } as const; + + // Clicking the empty space next to a point focuses that point. + instance.focusItem?.(identifier); + onItemClick?.(event.detail.srcEvent, identifier); } }); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts index 60268d3284dc5..b4300842e7a12 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts @@ -7,6 +7,7 @@ import type { UseChartHighlightSignature } from '../useChartHighlight'; import type { UseChartInteractionSignature } from '../useChartInteraction'; import type { UseChartTooltipSignature } from '../useChartTooltip'; import type { UseChartZAxisSignature } from '../useChartZAxis'; +import type { UseChartKeyboardNavigationSignature } from '../useChartKeyboardNavigation'; export interface UseChartVoronoiInstance { /** @@ -66,5 +67,6 @@ export type UseChartClosestPointSignature, UseChartTooltipSignature, UseChartZAxisSignature, + UseChartKeyboardNavigationSignature, ]; }>; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts index d1b377c021685..2f59c0c438ef4 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts @@ -2,7 +2,10 @@ import type { ChartPlugin } from '../../models'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; import type { UseChartItemClickSignature } from './useChartItemClick.types'; -import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; +import type { + FocusedItemIdentifier, + SeriesItemIdentifierWithType, +} from '../../../../models/seriesType'; import { getChartPoint } from '../../../getChartPoint'; export const useChartItemClick: ChartPlugin> = ({ @@ -12,10 +15,6 @@ export const useChartItemClick: ChartPlugin> = ( }) => { const { onItemClick } = params; - if (!onItemClick) { - return { instance: {} }; - } - const getItemPosition = (event: React.MouseEvent) => { const svgPoint = getChartPoint(event?.currentTarget, event); @@ -44,7 +43,8 @@ export const useChartItemClick: ChartPlugin> = ( handleClick: (event: React.MouseEvent) => { const item = getItemPosition(event); if (item !== undefined) { - onItemClick(event, item); + instance.focusItem?.(item as FocusedItemIdentifier); + onItemClick?.(event, item); } }, }, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts index 7f0627ffb267a..884527f28f33e 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts @@ -2,6 +2,7 @@ import type { ChartPluginSignature } from '../../models'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; import type { ChartSeriesTypeRequiredPlugins } from '../../corePlugins/useChartSeriesConfig'; +import type { UseChartKeyboardNavigationSignature } from '../useChartKeyboardNavigation'; export interface UseChartItemClickParameters { /** @@ -28,4 +29,5 @@ export type UseChartItemClickSignature; instance: UseChartItemClickInstance; dependencies: ChartSeriesTypeRequiredPlugins; + optionalDependencies: [UseChartKeyboardNavigationSignature]; }>; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts index 9c8e64dfe8410..3e9aa774c59dc 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.selectors.ts @@ -23,8 +23,7 @@ const selectKeyboardNavigation: ChartOptionalRootSelector keyboardNavigationState?.isFocused === true && keyboardNavigationState.isFocusVisible === true; export const selectorChartsIsFocusVisible = createSelector( diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 46799a979338a..5e9fcf920fadc 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -2,10 +2,11 @@ import * as React from 'react'; import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import useEventCallback from '@mui/utils/useEventCallback'; +import { fastObjectShallowCompare } from '@mui/x-internals/fastObjectShallowCompare'; import { selectorChartDefaultizedSeries } from '../../corePlugins/useChartSeries/useChartSeries.selectors'; import { selectorChartSeriesConfig } from '../../corePlugins/useChartSeriesConfig'; import { cleanIdentifier } from '../../corePlugins/useChartSeriesConfig/utils/cleanIdentifier'; -import { focusAccessibilityProxy } from '../../../components/ChartsAccessibilityProxy'; +import { focusAccessibilityProxy } from '../../../components/ChartsAccessibilityProxy/focusAccessibilityProxy'; import type { ChartPlugin } from '../../models'; import type { FocusItemOptions, @@ -119,6 +120,17 @@ export const useChartKeyboardNavigation: ChartPlugin, - options?: FocusItemOptions, - ) => boolean; + focusItem: (item: FocusedItemIdentifier, options?: FocusItemOptions) => boolean; /** * Moves the DOM focus into the chart without changing the focused item. * @param {FocusItemOptions} options Options to override the focus visibility. From e8012cde6367fe364eafce551caa624da609cf70 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:36:43 +0200 Subject: [PATCH 06/27] [charts] Test the click to focus behavior 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. --- .../ChartsAccessibilityProxy.tsx | 4 +- ...rtKeyboardNavigation.clickToFocus.test.tsx | 339 ++++++++++++++++++ .../useChartKeyboardNavigation.ts | 19 +- 3 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx index da08044fb5380..de369332d66ec 100644 --- a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx @@ -115,7 +115,9 @@ export function ChartsAccessibilityProxy() { return (
diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx new file mode 100644 index 0000000000000..2ff87200a01eb --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -0,0 +1,339 @@ +import { isJSDOM } from 'test/utils/skipIf'; +import { createRenderer } from '@mui/internal-test-utils/createRenderer'; +import { vi } from 'vitest'; +import { getCenter } from 'test/utils/charts/getCenter'; +import { BarChart, barClasses } from '@mui/x-charts/BarChart'; +import { chartsSvgLayerClasses } from '@mui/x-charts/ChartsSvgLayer'; +import { PieChart, pieClasses } from '@mui/x-charts/PieChart'; +import { LineChart, lineClasses } from '@mui/x-charts/LineChart'; +import { ScatterChart } from '@mui/x-charts/ScatterChart'; +import { RadarChart } from '@mui/x-charts/RadarChart'; + +describe('useChartKeyboardNavigation - click to focus', () => { + const { render } = createRenderer(); + + const FOCUS_INDICATOR_SELECTOR = '[fill="none"][stroke-width="2"]'; + + const barProps = { + height: 100, + width: 400, + skipAnimation: true, + margin: 0, + series: [{ id: 'A', data: [10, 20, 30, 40] }], + }; + + function getBars(container: HTMLElement) { + return container.querySelectorAll(`.${barClasses.series} rect`); + } + + function getLayerContainer(container: HTMLElement) { + return container.querySelector(`.${chartsSvgLayerClasses.root}`)!.parentElement!; + } + + /** Bars are resolved by a container hit test, so the click needs real coordinates. */ + function clickAt( + user: any, + container: HTMLElement, + coords: { clientX: number; clientY: number }, + ) { + return user.pointer([{ keys: '[MouseLeft]', target: getLayerContainer(container), coords }]); + } + + function getFocusIndicator(container: HTMLElement) { + return container.querySelector(FOCUS_INDICATOR_SELECTOR); + } + + function getFocusedDataIndex(container: HTMLElement) { + const indicator = getFocusIndicator(container); + if (!indicator) { + return null; + } + + const { clientX } = getCenter(indicator); + return Array.from(getBars(container)).findIndex( + (bar) => Math.abs(getCenter(bar).clientX - clientX) < 1, + ); + } + + // svg.createSVGPoint is not supported by JSDom https://github.com/jsdom/jsdom/issues/300 + describe.skipIf(isJSDOM)('bar chart', () => { + it('sets the focused item without showing it when the chart was not focused', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + + expect(getFocusIndicator(container)).to.equal(null); + expect(container.contains(document.activeElement)).to.equal(true); + }); + + it('resumes the keyboard navigation from the clicked item', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + await user.keyboard('[ArrowRight]'); + + expect(getFocusedDataIndex(container)).to.equal(2); + }); + + it('reveals the focus on the clicked item at the end of a series', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[3])); + // The last item does not move, but the key was handled so the focus must show up. + await user.keyboard('[ArrowRight]'); + + expect(getFocusedDataIndex(container)).to.equal(3); + }); + + it('moves the visible focus when the chart is already navigated with the keyboard', async () => { + const { container, user } = render(); + + await user.keyboard('{Tab}'); + await user.keyboard('[ArrowRight]'); + expect(getFocusedDataIndex(container)).to.equal(0); + + await clickAt(user, container, getCenter(getBars(container)[3])); + + expect(getFocusedDataIndex(container)).to.equal(3); + }); + + it('shows the focus right away with focusItemOnClick', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[2])); + + expect(getFocusedDataIndex(container)).to.equal(2); + }); + + it('does nothing when the keyboard navigation is disabled', async () => { + const { container, user } = render(); + const activeElementBefore = document.activeElement; + + await clickAt(user, container, getCenter(getBars(container)[1])); + + expect(getFocusIndicator(container)).to.equal(null); + expect(document.activeElement).to.equal(activeElementBefore); + }); + + it('keeps the highlight following the pointer after a hidden click focus', async () => { + const { container, user } = render( + , + ); + const bars = getBars(container); + + await clickAt(user, container, getCenter(bars[0])); + await user.pointer({ target: bars[2], coords: getCenter(bars[2]) }); + + // The click must not have switched the highlight to keyboard mode. + expect(bars[2].getAttribute('data-highlighted')).to.equal('true'); + expect(bars[0].getAttribute('data-highlighted')).to.equal(null); + }); + + it('focuses the chart without changing the item when the click resolves nothing', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + await user.keyboard('[ArrowRight]'); + expect(getFocusedDataIndex(container)).to.equal(2); + + // Above the shortest bar, so inside the drawing area but on no item. + const firstBar = getBars(container)[0].getBoundingClientRect(); + await clickAt(user, container, { + clientX: firstBar.left + firstBar.width / 2, + clientY: firstBar.top - 10, + }); + + expect(getFocusedDataIndex(container)).to.equal(2); + expect(container.contains(document.activeElement)).to.equal(true); + }); + + it('keeps the focus hidden when a background click follows a hidden click focus', async () => { + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + + const firstBar = getBars(container)[0].getBoundingClientRect(); + await clickAt(user, container, { + clientX: firstBar.left + firstBar.width / 2, + clientY: firstBar.top - 10, + }); + + expect(getFocusIndicator(container)).to.equal(null); + + // The clicked item is still the one navigation resumes from. + await user.keyboard('[ArrowRight]'); + expect(getFocusedDataIndex(container)).to.equal(2); + }); + + it('shows the focus again when tabbing back in after a hidden click focus', async () => { + const { container, user } = render( +
+ + +
, + ); + + await clickAt(user, container, getCenter(getBars(container)[1])); + expect(getFocusIndicator(container)).to.equal(null); + + await user.click(container.querySelector('#outside')!); + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + expect(getFocusedDataIndex(container)).to.equal(1); + }); + + it('fires onItemClick exactly once', async () => { + const onItemClick = vi.fn(); + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + + expect(onItemClick.mock.calls.length).to.equal(1); + expect(onItemClick.mock.lastCall?.[1]).to.deep.equal({ + type: 'bar', + seriesId: 'A', + dataIndex: 1, + }); + }); + }); + + describe.skipIf(isJSDOM)('scatter chart', () => { + const scatterProps = { + height: 200, + width: 200, + skipAnimation: true, + margin: 0, + xAxis: [{ position: 'none' as const, min: 0, max: 10 }], + yAxis: [{ position: 'none' as const, min: 0, max: 10 }], + series: [ + { + id: 'A', + data: [ + { x: 2, y: 2, id: 0 }, + { x: 8, y: 8, id: 1 }, + ], + }, + ], + }; + + it('focuses the closest point when clicking next to it', async () => { + const { container, user } = render(); + + const svg = container.querySelector('svg')!; + const { clientX, clientY } = getCenter(svg); + // Bottom left quadrant, closest to the first point. + await user.pointer([ + { + keys: '[MouseLeft]', + target: svg, + coords: { clientX: clientX - 40, clientY: clientY + 40 }, + }, + ]); + await user.keyboard('[ArrowRight]'); + + expect(getFocusIndicator(container)).not.to.equal(null); + }); + }); + + describe.skipIf(isJSDOM)('line chart', () => { + const lineProps = { + height: 100, + width: 400, + skipAnimation: true, + margin: 0, + xAxis: [{ position: 'none' as const, data: [0, 1, 2, 3] }], + yAxis: [{ position: 'none' as const }], + series: [{ id: 'A', data: [10, 20, 30, 40], showMark: true }], + }; + + it('focuses the item under a clicked mark', async () => { + const { container, user } = render(); + + const marks = container.querySelectorAll(`.${lineClasses.mark}`); + await user.click(marks[2]); + await user.keyboard('[ArrowRight]'); + + expect(getFocusIndicator(container)).not.to.equal(null); + }); + + it('focuses the item under a clicked line, without onItemClick', async () => { + const { container, user } = render( + , + ); + + const line = container.querySelector(`.${lineClasses.line}`)!; + await user.click(line); + await user.keyboard('[ArrowRight]'); + + expect(getFocusIndicator(container)).not.to.equal(null); + }); + }); + + describe('pie chart', () => { + const pieProps = { + height: 200, + width: 200, + hideLegend: true, + skipAnimation: true, + series: [ + { + id: 'pie', + data: [ + { id: 0, value: 10 }, + { id: 1, value: 20 }, + ], + }, + ], + }; + + it('sets the focused item without showing it, then reveals it on a key press', async () => { + const { container, user } = render(); + + const arcs = container.querySelectorAll(`.${pieClasses.arc}`); + await user.click(arcs[1]); + + expect(container.querySelector(`.${pieClasses.focusIndicator}`)).to.equal(null); + + await user.keyboard('[ArrowRight]'); + + expect(container.querySelector(`.${pieClasses.focusIndicator}`)).not.to.equal(null); + }); + + it('fires onItemClick exactly once', async () => { + const onItemClick = vi.fn(); + const { container, user } = render(); + + await user.click(container.querySelectorAll(`.${pieClasses.arc}`)[1]); + + expect(onItemClick.mock.calls.length).to.equal(1); + expect(onItemClick.mock.lastCall?.[1]).to.deep.equal({ + type: 'pie', + seriesId: 'pie', + dataIndex: 1, + }); + }); + }); + + describe.skipIf(isJSDOM)('radar chart', () => { + it('focuses a clicked mark', async () => { + const { container, user } = render( + {}} + />, + ); + + const marks = container.querySelectorAll('circle'); + await user.click(marks[1]); + await user.keyboard('[ArrowRight]'); + + expect(getFocusIndicator(container)).not.to.equal(null); + }); + }); +}); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 5e9fcf920fadc..ba35142207e3b 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -75,7 +75,7 @@ export const useChartKeyboardNavigation: ChartPlugin { element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); element.removeEventListener('pointerdown', trackPointerIntent, true); element.removeEventListener('click', focusChartOnClick); + document.removeEventListener('keydown', trackKeyboardIntent, true); }; }, [ chartsLayerContainerRef, From 1bedb13fc92b91fbb38a96c7a0eab56c191fd059 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 12:38:30 +0200 Subject: [PATCH 07/27] [docs] Document click to focus and focusItemOnClick 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. --- .../accessibility/KeyboardNavigation.js | 34 +++++++++++++++++-- .../accessibility/KeyboardNavigation.tsx | 33 ++++++++++++++++-- .../charts/accessibility/accessibility.md | 30 +++++++++++++++- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/data/charts/accessibility/KeyboardNavigation.js b/docs/data/charts/accessibility/KeyboardNavigation.js index f5be1a8427c99..78420dcde94e8 100644 --- a/docs/data/charts/accessibility/KeyboardNavigation.js +++ b/docs/data/charts/accessibility/KeyboardNavigation.js @@ -5,6 +5,8 @@ import Select from '@mui/material/Select'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import MenuItem from '@mui/material/MenuItem'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; import { ScatterChart } from '@mui/x-charts/ScatterChart'; import { BarChart } from '@mui/x-charts/BarChart'; import { LineChart } from '@mui/x-charts/LineChart'; @@ -33,6 +35,7 @@ const series = [ export default function KeyboardNavigation() { const [chartType, setChartType] = React.useState('line'); + const [focusItemOnClick, setFocusItemOnClick] = React.useState(false); const chartRef = React.useRef(null); const handleChange = (event) => setChartType(event.target.value); @@ -67,16 +70,38 @@ export default function KeyboardNavigation() { > Focus chart + setFocusItemOnClick(event.target.checked)} + /> + } + label="focusItemOnClick" + /> - + ); } -function Chart({ chartRef, type }) { +function Chart({ chartRef, type, focusItemOnClick }) { switch (type) { case 'scatter': - return ; + return ( + + ); + case 'line': return ( p.x1).toSorted((a, b) => a - b) }]} series={series} slotProps={{ tooltip: { trigger: 'item' } }} + focusItemOnClick={focusItemOnClick} /> ); @@ -98,6 +124,7 @@ function Chart({ chartRef, type }) { ]} slotProps={{ tooltip: { trigger: 'item' } }} series={series} + focusItemOnClick={focusItemOnClick} /> ); @@ -118,6 +145,7 @@ function Chart({ chartRef, type }) { ]} height={300} hideLegend={false} + focusItemOnClick={focusItemOnClick} /> ); diff --git a/docs/data/charts/accessibility/KeyboardNavigation.tsx b/docs/data/charts/accessibility/KeyboardNavigation.tsx index 9322eee94e227..6e8d922f9639d 100644 --- a/docs/data/charts/accessibility/KeyboardNavigation.tsx +++ b/docs/data/charts/accessibility/KeyboardNavigation.tsx @@ -5,6 +5,8 @@ import Select, { SelectChangeEvent } from '@mui/material/Select'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import MenuItem from '@mui/material/MenuItem'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; import { ScatterChart } from '@mui/x-charts/ScatterChart'; import { BarChart } from '@mui/x-charts/BarChart'; import { LineChart } from '@mui/x-charts/LineChart'; @@ -34,6 +36,7 @@ type ChartType = 'scatter' | 'line' | 'bar' | 'pie'; export default function KeyboardNavigation() { const [chartType, setChartType] = React.useState('line'); + const [focusItemOnClick, setFocusItemOnClick] = React.useState(false); const chartRef = React.useRef(null); const handleChange = (event: SelectChangeEvent) => @@ -71,8 +74,22 @@ export default function KeyboardNavigation() { > Focus chart + setFocusItemOnClick(event.target.checked)} + /> + } + label="focusItemOnClick" + /> - + ); } @@ -80,13 +97,22 @@ export default function KeyboardNavigation() { function Chart({ chartRef, type, + focusItemOnClick, }: { chartRef: React.RefObject; type: T; + focusItemOnClick: boolean; }) { switch (type) { case 'scatter': - return ; + return ( + + ); case 'line': return ( ({ xAxis={[{ data: data.map((p) => p.x1).toSorted((a, b) => a - b) }]} series={series} slotProps={{ tooltip: { trigger: 'item' } }} + focusItemOnClick={focusItemOnClick} /> ); case 'bar': @@ -107,6 +134,7 @@ function Chart({ ]} slotProps={{ tooltip: { trigger: 'item' } }} series={series} + focusItemOnClick={focusItemOnClick} /> ); case 'pie': @@ -126,6 +154,7 @@ function Chart({ ]} height={300} hideLegend={false} + focusItemOnClick={focusItemOnClick} /> ); diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index 8bb82a669a451..f24ae2050b06e 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -66,7 +66,7 @@ components: { {{"demo": "KeyboardNavigation.js"}} -This feature is currently supported by the following charts: line, bar, pie, scatter, sparkline, funnel, radar, heatmap, sankey, and range bar. +This feature is currently supported by the following charts: line, bar, pie, scatter, sparkline, funnel, radar, heatmap, sankey, map, radial bar, radial line, and range bar. This makes the SVG component focusable thanks to [`tabIndex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex). When focused, the chart highlights a value item that can be modified with arrow navigation. @@ -76,6 +76,34 @@ When focused, the chart highlights a value item that can be modified with arrow | Arrow Left, Arrow Right | Moves focus inside the series | | Arrow Up, Arrow Down | Move focus between series | +### Start from a clicked item + +Clicking an item makes it the item keyboard navigation resumes from, so users can jump straight to a distant data point instead of tabbing into the chart and pressing an arrow key repeatedly. +Screen readers announce the clicked item right away. + +How the focus indicator behaves depends on how the user got there: + +- If the chart was not being navigated with the keyboard, the click gives the chart focus but the indicator stays hidden. It appears on the next key press, on the item next to the one clicked. +- If the focus indicator is already visible, the click moves it to the clicked item and keeps it visible. + +Set `focusItemOnClick` to show the indicator immediately on every click: + +```jsx + +``` + +It can also be set globally using [theme default props](/material-ui/customization/theme-components/#theme-default-props): + +```js +components: { + MuiChartDataProvider: { + defaultProps: { + focusItemOnClick: true + }, + }, +} +``` + ## Screen reader compatibility Charts use a proxy strategy to support screen reader when user navigate with keyboard navigation. From 76e3facdb1cdf26a4dfa5dfc5b5553469c591b98 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 14:35:39 +0200 Subject: [PATCH 08/27] [charts] Resolve a click that hit no item from the axis 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. --- .../charts/accessibility/accessibility.md | 4 + ...rtKeyboardNavigation.clickToFocus.test.tsx | 48 ++++++-- .../useChartKeyboardNavigation.ts | 62 +++++++++- .../utils/getItemAtAxisPosition.test.ts | 92 +++++++++++++++ .../utils/getItemAtAxisPosition.ts | 106 ++++++++++++++++++ 5 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts create mode 100644 packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index f24ae2050b06e..982c59ce00e71 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -81,6 +81,10 @@ When focused, the chart highlights a value item that can be modified with arrow Clicking an item makes it the item keyboard navigation resumes from, so users can jump straight to a distant data point instead of tabbing into the chart and pressing an arrow key repeatedly. Screen readers announce the clicked item right away. +A click that lands on no item is resolved from the axis under the pointer, so clicking the empty space above a bar still focuses that bar's column. +The item is taken from the series being navigated, or from the first series when none is focused yet. +Clicks outside of any axis only give the chart focus, keeping the item that was focused before. + How the focus indicator behaves depends on how the user got there: - If the chart was not being navigated with the keyboard, the click gives the chart focus but the indicator stays hidden. It appears on the next key press, on the item next to the one clicked. diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 2ff87200a01eb..283286ee96a9e 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -132,24 +132,56 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(bars[0].getAttribute('data-highlighted')).to.equal(null); }); - it('focuses the chart without changing the item when the click resolves nothing', async () => { + it('falls back to the axis when the click hits no item', async () => { const { container, user } = render(); - await clickAt(user, container, getCenter(getBars(container)[1])); - await user.keyboard('[ArrowRight]'); - expect(getFocusedDataIndex(container)).to.equal(2); - // Above the shortest bar, so inside the drawing area but on no item. const firstBar = getBars(container)[0].getBoundingClientRect(); await clickAt(user, container, { clientX: firstBar.left + firstBar.width / 2, clientY: firstBar.top - 10, }); + await user.keyboard('[ArrowRight]'); - expect(getFocusedDataIndex(container)).to.equal(2); + // The axis under the pointer resolved index 0, so the arrow moved to 1. + expect(getFocusedDataIndex(container)).to.equal(1); expect(container.contains(document.activeElement)).to.equal(true); }); + it('keeps the focused series when falling back to the axis', async () => { + const { container, user } = render( + , + ); + + // Focus an item of the second series. + await user.keyboard('{Tab}'); + await user.keyboard('[ArrowRight]'); + await user.keyboard('[ArrowUp]'); + + const lastBarOfB = container + .querySelectorAll(`[data-series="B"] rect`)[3] + .getBoundingClientRect(); + await clickAt(user, container, { + clientX: lastBarOfB.left + lastBarOfB.width / 2, + clientY: lastBarOfB.top - 5, + }); + + // Still on series B, moved to the clicked column. + const indicator = getFocusIndicator(container)!; + const indicatorCenter = getCenter(indicator); + const barsOfB = container.querySelectorAll(`[data-series="B"] rect`); + const matched = Array.from(barsOfB).findIndex( + (bar) => Math.abs(getCenter(bar).clientX - indicatorCenter.clientX) < 1, + ); + expect(matched).to.equal(3); + }); + it('keeps the focus hidden when a background click follows a hidden click focus', async () => { const { container, user } = render(); @@ -163,9 +195,9 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(getFocusIndicator(container)).to.equal(null); - // The clicked item is still the one navigation resumes from. + // The axis fallback moved the item to the clicked column, still hidden. await user.keyboard('[ArrowRight]'); - expect(getFocusedDataIndex(container)).to.equal(2); + expect(getFocusedDataIndex(container)).to.equal(1); }); it('shows the focus again when tabbing back in after a hidden click focus', async () => { diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index ba35142207e3b..415328b3f78d5 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -7,6 +7,13 @@ import { selectorChartDefaultizedSeries } from '../../corePlugins/useChartSeries import { selectorChartSeriesConfig } from '../../corePlugins/useChartSeriesConfig'; import { cleanIdentifier } from '../../corePlugins/useChartSeriesConfig/utils/cleanIdentifier'; import { focusAccessibilityProxy } from '../../../components/ChartsAccessibilityProxy/focusAccessibilityProxy'; +import { selectorChartSeriesProcessed } from '../../corePlugins/useChartSeries'; +import { + selectorChartXAxis, + selectorChartYAxis, +} from '../useChartCartesianAxis/useChartCartesianAxisRendering.selectors'; +import { getChartPoint } from '../../../getChartPoint'; +import { getItemAtAxisPosition } from './utils/getItemAtAxisPosition'; import type { ChartPlugin } from '../../models'; import type { FocusItemOptions, @@ -39,6 +46,12 @@ export const useChartKeyboardNavigation: ChartPlugin params.focusItemOnClick === true || store.state.keyboardNavigation.isFocusVisible, ); @@ -112,6 +125,8 @@ export const useChartKeyboardNavigation: ChartPlugin { + const element = chartsLayerContainerRef.current; + + if (element === null || !store.state.keyboardNavigation.enabled) { + focusChart(); + return; + } + + const point = getChartPoint(element, event); + if (!instance.isPointInside?.(point.x, point.y)) { + focusChart(); + return; + } + + const item = getItemAtAxisPosition({ + point, + xAxis: selectorChartXAxis(store.state), + yAxis: selectorChartYAxis(store.state), + processedSeries: selectorChartSeriesProcessed(store.state), + focusedItem: store.state.keyboardNavigation.item, + }); + + if (item === null) { + focusChart(); + return; + } + + focusItem(item); + }); + React.useEffect(() => { const element = chartsLayerContainerRef.current; @@ -178,6 +227,7 @@ export const useChartKeyboardNavigation: ChartPlugin { diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts new file mode 100644 index 0000000000000..4fe37b2eb9832 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts @@ -0,0 +1,92 @@ +import { scaleBand } from '@mui/x-charts-vendor/d3-scale'; +import { getItemAtAxisPosition } from './getItemAtAxisPosition'; + +const xAxis = { + axisIds: ['x'], + axis: { + x: { + id: 'x', + data: ['A', 'B', 'C'], + // Three bands of 30px, from 0 to 90. + scale: scaleBand(['A', 'B', 'C'], [0, 90]), + }, + }, +} as any; + +const processedSeries = { + bar: { + seriesOrder: ['first', 'second'], + series: { + first: { id: 'first', type: 'bar', data: [1, 2, 3] }, + second: { id: 'second', type: 'bar', data: [4, 5, 6] }, + }, + }, +} as any; + +const resolve = (x: number, focusedItem: any = null) => + getItemAtAxisPosition({ + point: { x, y: 0 }, + xAxis, + yAxis: undefined, + processedSeries, + focusedItem, + }); + +describe('getItemAtAxisPosition', () => { + it('resolves the data index from the x axis, using the first series', () => { + expect(resolve(45)).to.deep.equal({ type: 'bar', seriesId: 'first', dataIndex: 1 }); + }); + + it('keeps the focused series', () => { + const focusedItem = { type: 'bar', seriesId: 'second', dataIndex: 0 }; + expect(resolve(75, focusedItem)).to.deep.equal({ + type: 'bar', + seriesId: 'second', + dataIndex: 2, + }); + }); + + it('falls back to the first series when the focused one is not cartesian', () => { + const focusedItem = { type: 'pie', seriesId: 'pie', dataIndex: 0 }; + expect(resolve(15, focusedItem)).to.deep.equal({ + type: 'bar', + seriesId: 'first', + dataIndex: 0, + }); + }); + + it('returns null outside of the axis', () => { + expect(resolve(500)).to.equal(null); + }); + + it('returns null without a cartesian axis', () => { + expect( + getItemAtAxisPosition({ + point: { x: 45, y: 0 }, + xAxis: undefined, + yAxis: undefined, + processedSeries, + focusedItem: null, + }), + ).to.equal(null); + }); + + it('clamps to the last item of a series shorter than the axis', () => { + const shortSeries = { + bar: { + seriesOrder: ['short'], + series: { short: { id: 'short', type: 'bar', data: [1, 2] } }, + }, + } as any; + + expect( + getItemAtAxisPosition({ + point: { x: 75, y: 0 }, + xAxis, + yAxis: undefined, + processedSeries: shortSeries, + focusedItem: null, + }), + ).to.deep.equal({ type: 'bar', seriesId: 'short', dataIndex: 1 }); + }); +}); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts new file mode 100644 index 0000000000000..5dd5590deaadb --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts @@ -0,0 +1,106 @@ +import type { SeriesId } from '../../../../../models/seriesType/common'; +import type { ChartSeriesType } from '../../../../../models/seriesType/config'; +import type { FocusedItemIdentifier } from '../../../../../models/seriesType'; +import { composableCartesianSeriesTypes } from '../../../../../models/seriesType/composition'; +import type { ProcessedSeries } from '../../../corePlugins/useChartSeries/useChartSeries.types'; +import type { ComputeResult } from '../../useChartCartesianAxis/computeAxisValue'; +import type { ChartsXAxisProps, ChartsYAxisProps } from '../../../../../models/axis'; +import { getAxisIndex } from '../../useChartCartesianAxis/getAxisValue'; +import { getNonEmptySeriesArray } from './getNonEmptySeriesArray'; +import { findVisibleDataIndex } from './findVisibleDataIndex'; + +/** + * Resolves the item a click falls on when it hit no item, by looking at the axis under the + * pointer. Keeps the focused series when there is one, so clicking the empty space above a bar + * moves along the series the user was already navigating. + * + * Returns `null` for charts without a cartesian axis, and when the axis resolves no index. + */ +export function getItemAtAxisPosition({ + point, + xAxis, + yAxis, + processedSeries, + focusedItem, +}: { + point: { x: number; y: number }; + xAxis: ComputeResult | undefined; + yAxis: ComputeResult | undefined; + processedSeries: ProcessedSeries; + focusedItem: FocusedItemIdentifier | null; +}): FocusedItemIdentifier | null { + const xAxisId = xAxis?.axisIds[0]; + const yAxisId = yAxis?.axisIds[0]; + const xAxisConfig = xAxisId === undefined ? undefined : xAxis?.axis[xAxisId]; + const yAxisConfig = yAxisId === undefined ? undefined : yAxis?.axis[yAxisId]; + + if (!xAxisConfig && !yAxisConfig) { + return null; + } + + const xIndex = xAxisConfig ? getAxisIndex(xAxisConfig, point.x) : -1; + const yIndex = yAxisConfig ? getAxisIndex(yAxisConfig, point.y) : -1; + const dataIndex = xIndex !== -1 ? xIndex : yIndex; + + if (dataIndex === -1) { + return null; + } + + const target = getTargetSeries(processedSeries, focusedItem); + if (target === null) { + return null; + } + + const data = processedSeries[target.type]?.series[target.seriesId]?.data as + ReadonlyArray | undefined; + if (!data || data.length === 0) { + return null; + } + + // The axis can be longer than the series it is shared with, and the index may be hidden. + const visibleDataIndex = + findVisibleDataIndex({ + processedSeries, + type: target.type, + seriesId: target.seriesId, + startIndex: Math.min(dataIndex, data.length - 1), + dataLength: data.length, + direction: 1, + allowCycles: false, + }) ?? + findVisibleDataIndex({ + processedSeries, + type: target.type, + seriesId: target.seriesId, + startIndex: Math.min(dataIndex, data.length - 1), + dataLength: data.length, + direction: -1, + allowCycles: false, + }); + + if (visibleDataIndex === null) { + return null; + } + + return { + type: target.type, + seriesId: target.seriesId, + dataIndex: visibleDataIndex, + } as FocusedItemIdentifier; +} + +/** The focused series when it is a cartesian one, the first series with data otherwise. */ +function getTargetSeries( + processedSeries: ProcessedSeries, + focusedItem: FocusedItemIdentifier | null, +): { type: ChartSeriesType; seriesId: SeriesId } | null { + if ( + focusedItem != null && + composableCartesianSeriesTypes.has(focusedItem.type as never) && + processedSeries[focusedItem.type]?.series[focusedItem.seriesId] !== undefined + ) { + return { type: focusedItem.type, seriesId: focusedItem.seriesId }; + } + + return getNonEmptySeriesArray(processedSeries, composableCartesianSeriesTypes)[0] ?? null; +} From fe087cb5f5e728fb8095616099fda2cf0706619a Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 15:26:06 +0200 Subject: [PATCH 09/27] [charts] Test the theme default props and fix the documented component 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. --- .../charts/accessibility/accessibility.md | 4 ++-- ...rtKeyboardNavigation.clickToFocus.test.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index 982c59ce00e71..908f58a5c84f3 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -56,7 +56,7 @@ You can also disable it globally using [theme default props](/material-ui/custom ```js components: { - MuiChartDataProvider: { + MuiChartsDataProvider: { defaultProps: { disableKeyboardNavigation: true }, @@ -100,7 +100,7 @@ It can also be set globally using [theme default props](/material-ui/customizati ```js components: { - MuiChartDataProvider: { + MuiChartsDataProvider: { defaultProps: { focusItemOnClick: true }, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 283286ee96a9e..120ca8514bf18 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -1,5 +1,6 @@ import { isJSDOM } from 'test/utils/skipIf'; import { createRenderer } from '@mui/internal-test-utils/createRenderer'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import { vi } from 'vitest'; import { getCenter } from 'test/utils/charts/getCenter'; import { BarChart, barClasses } from '@mui/x-charts/BarChart'; @@ -105,6 +106,24 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(getFocusedDataIndex(container)).to.equal(2); }); + it('reads focusItemOnClick from the theme default props', async () => { + const theme = createTheme({ + components: { + MuiChartsDataProvider: { defaultProps: { focusItemOnClick: true } }, + }, + }); + + const { container, user } = render( + + + , + ); + + await clickAt(user, container, getCenter(getBars(container)[2])); + + expect(getFocusedDataIndex(container)).to.equal(2); + }); + it('does nothing when the keyboard navigation is disabled', async () => { const { container, user } = render(); const activeElementBefore = document.activeElement; From c78d2c85f60642e5c54121fcb08afcc018ccafb1 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 15:43:42 +0200 Subject: [PATCH 10/27] [charts] Focus the clicked item in RadarSeriesPlot 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. --- .../RadarSeriesPlot/RadarSeriesPlot.test.tsx | 32 +++++++++++ .../RadarSeriesPlot/RadarSeriesPlot.tsx | 24 +++++--- ...rtKeyboardNavigation.clickToFocus.test.tsx | 57 ++++++++++++++----- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx index e40da8c30464c..9db8086478609 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx @@ -1,4 +1,7 @@ import { createRenderer } from '@mui/internal-test-utils/createRenderer'; +import { vi } from 'vitest'; +import { isJSDOM } from 'test/utils/skipIf'; +import { getCenter } from 'test/utils/charts/getCenter'; import { RadarChart, RadarSeriesPlot, radarClasses } from '@mui/x-charts/RadarChart'; describe('', () => { @@ -19,4 +22,33 @@ describe('', () => { const root = container.querySelector(`.${radarClasses.seriesRoot}.custom-radar-series`); expect(root).not.to.equal(null); }); + + // The interaction props now carry a click handler for the keyboard focus. It must be merged + // with the consumer callback, not replace it. + it.skipIf(isJSDOM)('should fire onAreaClick with the clicked rotation index', async () => { + const onAreaClick = vi.fn(); + const { container, user } = render( + + + , + ); + + const plot = container.querySelector('.custom-radar-series')!; + const area = plot.querySelector(`.${radarClasses.seriesArea}`)!; + const mark = plot.querySelectorAll('circle')[2]; + + await user.pointer([{ keys: '[MouseLeft]', target: area, coords: getCenter(mark) }]); + + expect(onAreaClick.mock.calls.length).to.equal(1); + expect(onAreaClick.mock.lastCall?.[1]).to.deep.equal({ + type: 'radar', + seriesId: 'radar', + dataIndex: 2, + }); + }); }); diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx index 3dcd44278a7f7..61ca8378ab7a6 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx @@ -8,6 +8,8 @@ import { useItemHighlightStateGetter } from '../../hooks/useItemHighlightStateGe import { getPathProps } from './RadarSeriesArea'; import { getCircleProps } from './RadarSeriesMarks'; import { useRadarRotationIndex } from './useRadarRotationIndex'; +import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; +import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; function RadarSeriesPlot(props: RadarSeriesPlotProps) { const { seriesId: inSeriesId, className, classes: inClasses, onAreaClick, onMarkClick } = props; @@ -16,6 +18,7 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { const interactionProps = useInteractionAllItemProps(seriesCoordinates); const getHighlightState = useItemHighlightStateGetter(); + const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); const classes = useUtilityClasses(inClasses); @@ -40,15 +43,18 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { getHighlightState, classes, })} - onClick={(event) => - onAreaClick?.(event, { + {...interactionProps[seriesIndex]} + // The area only knows its series, the data index comes from the click angle. + onClick={(event) => { + const identifier = { type: 'radar', seriesId, dataIndex: getRotationIndex(event), - }) - } + } as const; + instance.focusItem?.(identifier); + onAreaClick?.(event, identifier); + }} cursor={onAreaClick ? 'pointer' : 'unset'} - {...interactionProps[seriesIndex]} /> } {!hideMark && @@ -63,9 +69,11 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { getHighlightState, classes, })} - onClick={(event) => - onMarkClick?.(event, { type: 'radar', seriesId, dataIndex: index }) - } + onClick={(event) => { + const identifier = { type: 'radar', seriesId, dataIndex: index } as const; + instance.focusItem?.(identifier); + onMarkClick?.(event, identifier); + }} cursor={onMarkClick ? 'pointer' : 'unset'} /> ))} diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 120ca8514bf18..6a456ed822544 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -8,7 +8,7 @@ import { chartsSvgLayerClasses } from '@mui/x-charts/ChartsSvgLayer'; import { PieChart, pieClasses } from '@mui/x-charts/PieChart'; import { LineChart, lineClasses } from '@mui/x-charts/LineChart'; import { ScatterChart } from '@mui/x-charts/ScatterChart'; -import { RadarChart } from '@mui/x-charts/RadarChart'; +import { RadarChart, radarClasses } from '@mui/x-charts/RadarChart'; describe('useChartKeyboardNavigation - click to focus', () => { const { render } = createRenderer(); @@ -369,22 +369,53 @@ describe('useChartKeyboardNavigation - click to focus', () => { }); describe.skipIf(isJSDOM)('radar chart', () => { - it('focuses a clicked mark', async () => { - const { container, user } = render( - {}} - />, - ); + const radarProps = { + height: 300, + width: 300, + radar: { metrics: ['A', 'B', 'C', 'D'] }, + series: [{ id: 'radar', data: [10, 20, 30, 40] }], + }; + + /** The focus indicator is a rect centered on the focused point. */ + function getFocusedMarkIndex(container: HTMLElement) { + const indicator = getFocusIndicator(container); + if (!indicator) { + return null; + } + + const center = getCenter(indicator); + return Array.from(container.querySelectorAll('circle')).findIndex((mark) => { + const markCenter = getCenter(mark); + return ( + Math.abs(markCenter.clientX - center.clientX) < 2 && + Math.abs(markCenter.clientY - center.clientY) < 2 + ); + }); + } + + it('focuses the clicked mark', async () => { + const { container, user } = render( {}} />); + + await user.click(container.querySelectorAll('circle')[2]); + + // Hidden, but stored: the next key moves on from the clicked mark. + expect(getFocusIndicator(container)).to.equal(null); + await user.keyboard('[ArrowRight]'); + + expect(getFocusedMarkIndex(container)).to.equal(3); + }); + + it('focuses the mark through the area when no click callback is set', async () => { + // Marks are pointer transparent without `onMarkClick`, so the click lands on the area, + // which resolves the index from the click angle. + const { container, user } = render(); const marks = container.querySelectorAll('circle'); - await user.click(marks[1]); + const area = container.querySelector(`.${radarClasses.seriesArea}`)!; + await user.pointer([{ keys: '[MouseLeft]', target: area, coords: getCenter(marks[2]) }]); await user.keyboard('[ArrowRight]'); - expect(getFocusIndicator(container)).not.to.equal(null); + expect(getFocusedMarkIndex(container)).to.equal(3); }); }); }); From c149a2d776cb9081fa358f57b68d5a42c9b6f21c Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 29 Jul 2026 17:17:45 +0200 Subject: [PATCH 11/27] [charts] Resolve the axis fallback against the target series axis 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. --- .../async/ScatterAsyncBatch.test.ts | 56 +++++++++++++++++++ .../ScatterChart/async/ScatterAsyncBatch.tsx | 53 ++++++++++++++---- .../utils/getItemAtAxisPosition.test.ts | 44 +++++++++++++++ .../utils/getItemAtAxisPosition.ts | 34 ++++++----- 4 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts new file mode 100644 index 0000000000000..a4b7d810d40b4 --- /dev/null +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts @@ -0,0 +1,56 @@ +import { vi } from 'vitest'; +import { getMarkerInteractionProps } from './ScatterAsyncBatch'; + +const instance = { + setLastUpdateSource: () => {}, + setTooltipItem: () => {}, + setHighlight: () => {}, + removeTooltipItem: () => {}, + clearHighlight: () => {}, + focusItem: vi.fn(), +} as any; + +const dataPoint = { x: 0, y: 0, dataIndex: 3, seriesId: 'A', type: 'scatter' } as any; + +const build = (overrides: Record) => + getMarkerInteractionProps({ + instance, + dataPoint, + seriesId: 'A', + dataIndex: 3, + onItemClick: undefined, + skipInteractionHandlers: false, + isInteracting: false, + ...overrides, + } as any); + +describe('getMarkerInteractionProps', () => { + it('returns the pointer handlers and the click by default', () => { + const props = build({})!; + expect(props.onClick).not.to.equal(undefined); + expect(props.onPointerEnter).not.to.equal(undefined); + }); + + it('keeps the click while a zoom interaction is in progress', () => { + const props = build({ isInteracting: true })!; + // Pointer handlers are dropped for performance, the click still fires the callback. + expect(props.onClick).not.to.equal(undefined); + expect(props.onPointerEnter).to.equal(undefined); + }); + + it('forwards onItemClick with the item identifier', () => { + const onItemClick = vi.fn(); + build({ onItemClick, isInteracting: true })!.onClick!({} as any); + + expect(onItemClick.mock.calls.length).to.equal(1); + expect(onItemClick.mock.lastCall?.[1]).to.deep.equal({ + type: 'scatter', + seriesId: 'A', + dataIndex: 3, + }); + }); + + it('drops every handler when the closest point plugin owns the series', () => { + expect(build({ skipInteractionHandlers: true })).to.equal(undefined); + }); +}); diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index 184414f6a4daf..c2f9e1a57fa9e 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -17,6 +17,7 @@ import type { UseChartInteractionSignature } from '../../internals/plugins/featu import type { UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight'; import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; import type { ScatterProps } from '../Scatter'; +import type { SeriesId } from '../../models/seriesType/common'; import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; export interface ScatterAsyncBatchProps extends Pick< @@ -42,6 +43,39 @@ export interface ScatterAsyncBatchProps extends Pick< isInteracting?: boolean; } +/** + * Pointer handlers are dropped mid zoom, where per-marker highlight is useless and the dominant + * per-frame cost. The click is kept: it drives `onItemClick` and the keyboard focus, and neither + * is on the drag path. Both are dropped when the closest point plugin handles the series instead. + */ +export function getMarkerInteractionProps({ + instance, + dataPoint, + seriesId, + dataIndex, + onItemClick, + skipInteractionHandlers, + isInteracting, +}: { + instance: Parameters>[0]; + dataPoint: Parameters>[1]; + seriesId: SeriesId; + dataIndex: number; + onItemClick: ScatterProps['onItemClick']; + skipInteractionHandlers: boolean | undefined; + isInteracting?: boolean; +}) { + if (skipInteractionHandlers) { + return undefined; + } + + const { onClick, ...pointerProps } = getInteractionItemProps(instance, dataPoint, { + onClick: (event) => onItemClick?.(event, { type: 'scatter', seriesId, dataIndex }), + }); + + return isInteracting ? { onClick } : { onClick, ...pointerProps }; +} + /** * @ignore - internal component. */ @@ -121,16 +155,15 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { y={y} data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} - {...(skipInteractionHandlers || isInteracting - ? undefined - : getInteractionItemProps(instance, dataPoint, { - onClick: (event) => - onItemClick?.(event, { - type: 'scatter', - seriesId: series.id, - dataIndex, - }), - }))} + {...getMarkerInteractionProps({ + instance, + dataPoint, + seriesId: series.id, + dataIndex, + onItemClick, + skipInteractionHandlers, + isInteracting, + })} {...markerProps} />, ); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts index 4fe37b2eb9832..81acd33d918f7 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts @@ -55,6 +55,50 @@ describe('getItemAtAxisPosition', () => { }); }); + it('reads the index off the axis the target series is bound to', () => { + // Two x axes with different band counts over the same range. + const multiAxis = { + axisIds: ['default', 'secondary'], + axis: { + default: { + id: 'default', + data: ['A', 'B', 'C'], + scale: scaleBand(['A', 'B', 'C'], [0, 90]), + }, + secondary: { + id: 'secondary', + data: ['A', 'B', 'C', 'D', 'E', 'F'], + scale: scaleBand(['A', 'B', 'C', 'D', 'E', 'F'], [0, 90]), + }, + }, + } as any; + + const series = { + bar: { + seriesOrder: ['onSecondary'], + series: { + onSecondary: { + id: 'onSecondary', + type: 'bar', + xAxisId: 'secondary', + data: [1, 2, 3, 4, 5, 6], + }, + }, + }, + } as any; + + // x=45 is band 1 of 3 on the default axis, but band 3 of 6 on the secondary one. + expect( + getItemAtAxisPosition({ + point: { x: 45, y: 0 }, + xAxis: multiAxis, + yAxis: undefined, + processedSeries: series, + focusedItem: null, + }), + ).to.deep.equal({ type: 'bar', seriesId: 'onSecondary', dataIndex: 3 }); + }); + it('returns null outside of the axis', () => { expect(resolve(500)).to.equal(null); }); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts index 5dd5590deaadb..35eff653b9b6a 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts @@ -4,7 +4,7 @@ import type { FocusedItemIdentifier } from '../../../../../models/seriesType'; import { composableCartesianSeriesTypes } from '../../../../../models/seriesType/composition'; import type { ProcessedSeries } from '../../../corePlugins/useChartSeries/useChartSeries.types'; import type { ComputeResult } from '../../useChartCartesianAxis/computeAxisValue'; -import type { ChartsXAxisProps, ChartsYAxisProps } from '../../../../../models/axis'; +import type { AxisId, ChartsXAxisProps, ChartsYAxisProps } from '../../../../../models/axis'; import { getAxisIndex } from '../../useChartCartesianAxis/getAxisValue'; import { getNonEmptySeriesArray } from './getNonEmptySeriesArray'; import { findVisibleDataIndex } from './findVisibleDataIndex'; @@ -29,8 +29,25 @@ export function getItemAtAxisPosition({ processedSeries: ProcessedSeries; focusedItem: FocusedItemIdentifier | null; }): FocusedItemIdentifier | null { - const xAxisId = xAxis?.axisIds[0]; - const yAxisId = yAxis?.axisIds[0]; + const target = getTargetSeries(processedSeries, focusedItem); + if (target === null) { + return null; + } + + const series = processedSeries[target.type]?.series[target.seriesId]; + const data = series?.data as ReadonlyArray | undefined; + if (!data || data.length === 0) { + return null; + } + + // Read the index off the axis the target series is bound to, which is not necessarily the + // default one on a chart with several axes. + const xAxisId = + (series && 'xAxisId' in series ? (series.xAxisId as AxisId | undefined) : undefined) ?? + xAxis?.axisIds[0]; + const yAxisId = + (series && 'yAxisId' in series ? (series.yAxisId as AxisId | undefined) : undefined) ?? + yAxis?.axisIds[0]; const xAxisConfig = xAxisId === undefined ? undefined : xAxis?.axis[xAxisId]; const yAxisConfig = yAxisId === undefined ? undefined : yAxis?.axis[yAxisId]; @@ -46,17 +63,6 @@ export function getItemAtAxisPosition({ return null; } - const target = getTargetSeries(processedSeries, focusedItem); - if (target === null) { - return null; - } - - const data = processedSeries[target.type]?.series[target.seriesId]?.data as - ReadonlyArray | undefined; - if (!data || data.length === 0) { - return null; - } - // The axis can be longer than the series it is shared with, and the index may be hidden. const visibleDataIndex = findVisibleDataIndex({ From 8049d3c7a01eae1bbc32ec243e28d5fce335476e Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 10:47:48 +0200 Subject: [PATCH 12/27] [charts] Do not claim a focus the chart could not take `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. --- .../x-charts/src/hooks/useInteractionItemProps.ts | 12 ++++++++++++ .../useChartKeyboardNavigation.ts | 15 +++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 9a0d3ece4e4f0..36a55b260b516 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -41,6 +41,14 @@ function onPointerDown(event: React.PointerEvent) { } } +/** + * Props to make a series item interactive: pointer handlers driving the highlight and the + * tooltip, and a click that makes the item the one keyboard navigation resumes from. + * + * The returned `onClick` merges `options.onClick`, so pass the consumer callback through the + * options rather than setting `onClick` on the element. Spreading these props next to a separate + * `onClick` makes the two clobber each other depending on the spread order. + */ export const useInteractionItemProps = ( data: SeriesItemIdentifierWithType, options?: InteractionItemOptions, @@ -85,6 +93,10 @@ export const useInteractionItemProps = ( ); }; +/** + * Loop friendly variant of `useInteractionItemProps`, for series rendering many items. + * The same merge rule applies to `options.onClick`. + */ export function getInteractionItemProps( instance: ChartInstance>, item: SeriesItemIdentifierWithType, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 415328b3f78d5..6ed5be3ba2eb3 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -59,25 +59,32 @@ export const useChartKeyboardNavigation: ChartPlugin | null | undefined, isFocusVisible: boolean) => { const keyboardNavigation = store.state.keyboardNavigation; + const isFocused = chartsLayerContainerRef.current?.contains(document.activeElement) ?? false; store.update({ - ...(isFocusVisible && + ...(isFocused && + isFocusVisible && store.state.highlight && { highlight: { ...store.state.highlight, lastUpdate: 'keyboard' }, }), - ...(isFocusVisible && + ...(isFocused && + isFocusVisible && store.state.interaction && { interaction: { ...store.state.interaction, lastUpdate: 'keyboard' }, }), keyboardNavigation: { ...keyboardNavigation, ...(item !== undefined && { item }), - isFocused: true, - isFocusVisible, + isFocused, + isFocusVisible: isFocused && isFocusVisible, }, }); }, From 91a8a597c2210deec01fbda988ee8a8f7272fb87 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 10:58:41 +0200 Subject: [PATCH 13/27] [charts] Keep the pointer cursor tied to onItemClick 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. --- .../BarChartPremium/RangeBar/RangeBarPlot.tsx | 1 + packages/x-charts/src/BarChart/BarElement.tsx | 4 +- .../x-charts/src/ScatterChart/Scatter.tsx | 1 + .../src/ScatterChart/ScatterMarker.tsx | 19 ++++++- .../ScatterChart/async/ScatterAsyncBatch.tsx | 1 + ...rtKeyboardNavigation.clickToFocus.test.tsx | 49 +++++++++++++++++++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx index 8dba37420420b..607fc405db7b8 100644 --- a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx +++ b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx @@ -131,6 +131,7 @@ function RangeBarSvgPlot(props: Omit): React.JSX. ry={borderRadius} {...other} slots={slots} + cursor={onItemClick ? 'pointer' : undefined} onClick={(event) => { const identifier = { type: 'rangeBar', seriesId, dataIndex } as const; instance.focusItem?.(identifier); diff --git a/packages/x-charts/src/BarChart/BarElement.tsx b/packages/x-charts/src/BarChart/BarElement.tsx index f5732e38cf874..6bc313064804f 100644 --- a/packages/x-charts/src/BarChart/BarElement.tsx +++ b/packages/x-charts/src/BarChart/BarElement.tsx @@ -68,6 +68,7 @@ function BarElement(props: BarElementProps) { width, height, hidden, + cursor, ...other } = props; const itemIdentifier = React.useMemo( @@ -109,7 +110,8 @@ function BarElement(props: BarElementProps) { height, style, onClick, - cursor: onClick ? 'pointer' : undefined, + // `onClick` is always defined, it also drives the keyboard focus. + cursor: cursor ?? (onClick ? 'pointer' : undefined), stroke: 'none', fill: color, skipAnimation, diff --git a/packages/x-charts/src/ScatterChart/Scatter.tsx b/packages/x-charts/src/ScatterChart/Scatter.tsx index 6e7c4c758bf3c..b8a537ea9e4cb 100644 --- a/packages/x-charts/src/ScatterChart/Scatter.tsx +++ b/packages/x-charts/src/ScatterChart/Scatter.tsx @@ -140,6 +140,7 @@ function Scatter(props: ScatterProps) { isFaded={isItemFaded} x={dataPoint.x} y={dataPoint.y} + cursor={onItemClick ? 'pointer' : 'unset'} data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} {...(skipInteractionHandlers diff --git a/packages/x-charts/src/ScatterChart/ScatterMarker.tsx b/packages/x-charts/src/ScatterChart/ScatterMarker.tsx index 70ba1c1163dcd..c4247f5f5c382 100644 --- a/packages/x-charts/src/ScatterChart/ScatterMarker.tsx +++ b/packages/x-charts/src/ScatterChart/ScatterMarker.tsx @@ -37,13 +37,21 @@ export interface ScatterMarkerProps { isFaded: boolean; /** * Callback fired when clicking on a scatter item. + * It is always defined: besides `onItemClick`, it makes the item the one keyboard navigation + * resumes from. Use `cursor` to tell whether the chart is interactive. * @param {MouseEvent} event Mouse event recorded on the `` element. */ onClick?: (event: React.MouseEvent) => void; + /** + * The cursor to display over the marker. + * @default 'unset' + */ + cursor?: React.SVGAttributes['cursor']; } function ScatterMarker(props: ScatterMarkerProps) { - const { seriesId, isFaded, isHighlighted, x, y, color, size, dataIndex, ...other } = props; + const { seriesId, isFaded, isHighlighted, x, y, color, size, dataIndex, cursor, ...other } = + props; return ( ); @@ -68,6 +76,11 @@ ScatterMarker.propTypes /* remove-proptypes */ = { * The fill color of the marker. */ color: PropTypes.string.isRequired, + /** + * The cursor to display over the marker. + * @default 'unset' + */ + cursor: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** * The index of the data point. */ @@ -82,6 +95,8 @@ ScatterMarker.propTypes /* remove-proptypes */ = { isHighlighted: PropTypes.bool.isRequired, /** * Callback fired when clicking on a scatter item. + * It is always defined: besides `onItemClick`, it makes the item the one keyboard navigation + * resumes from. Use `cursor` to tell whether the chart is interactive. * @param {MouseEvent} event Mouse event recorded on the `` element. */ onClick: PropTypes.func, diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index c2f9e1a57fa9e..3a868cfc6ff81 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -153,6 +153,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { isFaded={isItemFaded} x={x} y={y} + cursor={onItemClick ? 'pointer' : 'unset'} data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} {...getMarkerInteractionProps({ diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 6a456ed822544..c748f8c157f84 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -289,6 +289,55 @@ describe('useChartKeyboardNavigation - click to focus', () => { }); }); + // The click handlers are now always attached, so the pointer cursor must keep coming from + // `onItemClick` rather than from the presence of a handler. + describe('cursor', () => { + it('leaves the scatter markers alone without onItemClick', () => { + const { container } = render( + , + ); + + expect(container.querySelector('circle')?.getAttribute('cursor')).to.equal('unset'); + }); + + it('marks the scatter markers as clickable with onItemClick', () => { + const { container } = render( + {}} + series={[{ id: 'A', data: [{ x: 1, y: 1, id: 0 }] }]} + />, + ); + + expect(container.querySelector('circle')?.getAttribute('cursor')).to.equal('pointer'); + }); + + it('leaves the line and area paths alone without onItemClick', () => { + const { container } = render( + , + ); + + expect(container.querySelector(`.${lineClasses.line}`)?.getAttribute('cursor')).to.equal( + 'unset', + ); + expect(container.querySelector(`.${lineClasses.area}`)?.getAttribute('cursor')).to.equal( + 'unset', + ); + }); + }); + describe.skipIf(isJSDOM)('line chart', () => { const lineProps = { height: 100, From 67b7f5328a9c2ba1059ef9728c86d3adb35c026c Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 10:59:30 +0200 Subject: [PATCH 14/27] [docs] Document click to focus for custom components 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. --- .../charts/accessibility/accessibility.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index 908f58a5c84f3..7138a27478aac 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -134,3 +134,47 @@ import { FocusedHeatmapCell } from '@mui/x-charts-pro/Heatmap'; import { FocusedFunnelSection } from '@mui/x-charts-pro/FunnelChart'; import { FocusedSankeyLink, FocusedSankeyNode } from '@mui/x-charts-pro/SankeyChart'; ``` + +### Custom components + +Custom components rendered through a slot receive an `onClick` handler that makes their item the one keyboard navigation resumes from. +Forward it to the rendered element, along with the rest of the props, for click-to-focus to work: + +```jsx +function CustomMarker({ + x, + y, + color, + size, + seriesId, + dataIndex, + isHighlighted, + isFaded, + ...other +}) { + // `other` carries the click handler and the interaction handlers. + return ( + + ); +} +``` + +The handler is attached whether or not you pass a click callback, so it can't tell you whether the chart is interactive. +Read the `cursor` prop instead of checking for `onClick`. + +When building a plot from scratch rather than through a slot, `useInteractionItemProps` returns the handlers for an item, including the click: + +```jsx +import { useInteractionItemProps } from '@mui/x-charts/internals'; + +function CustomPlotItem({ seriesId, dataIndex, onClick }) { + // Pass your own callback through the options: the returned `onClick` merges the two. + // Setting `onClick` on the element next to the spread makes them clobber each other. + const interactionProps = useInteractionItemProps( + { type: 'bar', seriesId, dataIndex }, + { onClick }, + ); + + return ; +} +``` From 690ec248f804f3f5671f10b035960a4745ac584f Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 11:08:57 +0200 Subject: [PATCH 15/27] [charts] Merge the focus entry points into one path `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. --- .../useChartKeyboardNavigation.ts | 112 ++++++++---------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 6ed5be3ba2eb3..2225bb05b440f 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -90,34 +90,53 @@ export const useChartKeyboardNavigation: ChartPlugin { - if (!store.state.keyboardNavigation.enabled) { - return; - } + /** + * Moves the DOM focus into the chart and writes the focus state. + * An `undefined` item focuses the chart without changing the focused item. + * @returns `true` when the state changed. + */ + const applyFocus = useEventCallback( + (item: FocusedItemIdentifier | undefined, options?: FocusItemOptions) => { + if (!store.state.keyboardNavigation.enabled) { + return false; + } - const isFocusVisible = options?.visible ?? focusVisibleIntentRef.current; - focusVisibleIntentRef.current = isFocusVisible; + const isFocusVisible = options?.visible ?? focusVisibleIntentRef.current; + focusVisibleIntentRef.current = isFocusVisible; - const container = chartsLayerContainerRef.current; - if (!container?.contains(document.activeElement)) { - // Focusing fires `focusin` synchronously, which runs `restoreFocus` with the intent above. - focusAccessibilityProxy(chartsAccessibilityProxyRef.current); - } + const container = chartsLayerContainerRef.current; + if (!container?.contains(document.activeElement)) { + // Focusing fires `focusin` synchronously, which runs `restoreFocus` with the intent above. + focusAccessibilityProxy(chartsAccessibilityProxyRef.current); + } - const keyboardNavigation = store.state.keyboardNavigation; - if (keyboardNavigation.isFocused && keyboardNavigation.isFocusVisible === isFocusVisible) { - return; - } + // Read after focusing, `restoreFocus` already ran. + const keyboardNavigation = store.state.keyboardNavigation; + const isSameItem = + item === undefined || + (keyboardNavigation.item != null && + fastObjectShallowCompare(keyboardNavigation.item, item)); - updateFocus(undefined, isFocusVisible); + if ( + keyboardNavigation.isFocused && + keyboardNavigation.isFocusVisible === isFocusVisible && + isSameItem + ) { + // Nothing to do: two click paths can resolve the same item, a line mark over its line. + return false; + } + + updateFocus(item, isFocusVisible); + return true; + }, + ); + + const focusChart = useEventCallback((options?: FocusItemOptions) => { + applyFocus(undefined, options); }); const focusItem = useEventCallback( (item: FocusedItemIdentifier, options?: FocusItemOptions) => { - if (!store.state.keyboardNavigation.enabled) { - return false; - } - const seriesConfig = selectorChartSeriesConfig(store.state); if (!seriesConfig[item.type]?.keyboardFocusHandler) { // The series type is not part of the keyboard navigation. @@ -134,27 +153,7 @@ export const useChartKeyboardNavigation: ChartPlugin { const element = chartsLayerContainerRef.current; - - if (element === null || !store.state.keyboardNavigation.enabled) { - focusChart(); - return; - } - - const point = getChartPoint(element, event); - if (!instance.isPointInside?.(point.x, point.y)) { - focusChart(); - return; - } - - const item = getItemAtAxisPosition({ - point, - xAxis: selectorChartXAxis(store.state), - yAxis: selectorChartYAxis(store.state), - processedSeries: selectorChartSeriesProcessed(store.state), - focusedItem: store.state.keyboardNavigation.item, - }); + const point = element === null ? null : getChartPoint(element, event); + + const item = + point && instance.isPointInside?.(point.x, point.y) + ? getItemAtAxisPosition({ + point, + xAxis: selectorChartXAxis(store.state), + yAxis: selectorChartYAxis(store.state), + processedSeries: selectorChartSeriesProcessed(store.state), + focusedItem: store.state.keyboardNavigation.item, + }) + : null; if (item === null) { focusChart(); From 13878a24e2770418b40c2b9accc8dec8cfcd53ad Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 11:16:42 +0200 Subject: [PATCH 16/27] [charts] Leave the radial charts out of click to focus 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. --- docs/pages/x/api/charts/radial-bar-chart.json | 7 ------- docs/pages/x/api/charts/radial-line-chart.json | 7 ------- .../api-docs/charts/radial-bar-chart/radial-bar-chart.json | 7 ------- .../charts/radial-line-chart/radial-line-chart.json | 7 ------- .../src/RadialBarChart/RadialBarChart.plugins.ts | 4 ---- .../x-charts-premium/src/RadialBarChart/RadialBarChart.tsx | 7 ------- .../src/RadialLineChart/RadialLineChart.plugins.ts | 4 ---- .../src/RadialLineChart/RadialLineChart.tsx | 7 ------- 8 files changed, 50 deletions(-) diff --git a/docs/pages/x/api/charts/radial-bar-chart.json b/docs/pages/x/api/charts/radial-bar-chart.json index 0aae81651ddd5..5e47854b14712 100644 --- a/docs/pages/x/api/charts/radial-bar-chart.json +++ b/docs/pages/x/api/charts/radial-bar-chart.json @@ -75,13 +75,6 @@ "describedArgs": ["highlightedItem"] } }, - "onItemClick": { - "type": { "name": "func" }, - "signature": { - "type": "function(event: React.MouseEvent, item: SeriesItemIdentifierWithType) => void", - "describedArgs": ["event", "item"] - } - }, "onTooltipItemChange": { "type": { "name": "func" }, "signature": { diff --git a/docs/pages/x/api/charts/radial-line-chart.json b/docs/pages/x/api/charts/radial-line-chart.json index 8557d69c95cdd..0db5f91ef5f91 100644 --- a/docs/pages/x/api/charts/radial-line-chart.json +++ b/docs/pages/x/api/charts/radial-line-chart.json @@ -76,13 +76,6 @@ "describedArgs": ["highlightedItem"] } }, - "onItemClick": { - "type": { "name": "func" }, - "signature": { - "type": "function(event: React.MouseEvent, item: SeriesItemIdentifierWithType) => void", - "describedArgs": ["event", "item"] - } - }, "onTooltipItemChange": { "type": { "name": "func" }, "signature": { diff --git a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json index c11593cbc93e8..4810bd8bf79bf 100644 --- a/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json +++ b/docs/translations/api-docs/charts/radial-bar-chart/radial-bar-chart.json @@ -74,13 +74,6 @@ } } }, - "onItemClick": { - "description": "The callback fired when an item is clicked.", - "typeDescriptions": { - "event": { "name": "event", "description": "The click event." }, - "item": { "name": "item", "description": "The clicked item." } - } - }, "onTooltipItemChange": { "description": "The callback fired when the tooltip item changes.", "typeDescriptions": { diff --git a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json index 9be6ef7913ca5..bd55ae63144ce 100644 --- a/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json +++ b/docs/translations/api-docs/charts/radial-line-chart/radial-line-chart.json @@ -77,13 +77,6 @@ } } }, - "onItemClick": { - "description": "The callback fired when an item is clicked.", - "typeDescriptions": { - "event": { "name": "event", "description": "The click event." }, - "item": { "name": "item", "description": "The clicked item." } - } - }, "onTooltipItemChange": { "description": "The callback fired when the tooltip item changes.", "typeDescriptions": { diff --git a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.plugins.ts b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.plugins.ts index 4ab0846f31d99..cb036a357d905 100644 --- a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.plugins.ts +++ b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.plugins.ts @@ -5,7 +5,6 @@ import { useChartInteraction, useChartHighlight, useChartKeyboardNavigation, - useChartItemClick, useChartVisibilityManager, } from '@mui/x-charts/internals'; import type { @@ -15,7 +14,6 @@ import type { UseChartInteractionSignature, UseChartHighlightSignature, UseChartKeyboardNavigationSignature, - UseChartItemClickSignature, UseChartVisibilityManagerSignature, ConvertSignaturesIntoPlugins, } from '@mui/x-charts/internals'; @@ -30,7 +28,6 @@ export type RadialBarChartPluginSignatures = [ UseChartHighlightSignature<'radialBar'>, UseChartVisibilityManagerSignature<'radialBar'>, UseChartKeyboardNavigationSignature, - UseChartItemClickSignature<'radialBar'>, UseChartProExportSignature, ]; @@ -43,6 +40,5 @@ export const RADIAL_BAR_CHART_PLUGINS: ConvertSignaturesIntoPlugins | null} highlightedItem The newly highlighted item. */ onHighlightChange: PropTypes.func, - /** - * The callback fired when an item is clicked. - * - * @param {React.MouseEvent} event The click event. - * @param {SeriesItemIdentifierWithType} item The clicked item. - */ - onItemClick: PropTypes.func, /** * The callback fired when the tooltip item changes. * diff --git a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts index ad3677f3a41f6..55a6dc1dd539d 100644 --- a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts +++ b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.plugins.ts @@ -5,7 +5,6 @@ import { useChartInteraction, useChartHighlight, useChartKeyboardNavigation, - useChartItemClick, useChartVisibilityManager, } from '@mui/x-charts/internals'; import type { @@ -15,7 +14,6 @@ import type { UseChartInteractionSignature, UseChartHighlightSignature, UseChartKeyboardNavigationSignature, - UseChartItemClickSignature, UseChartVisibilityManagerSignature, ConvertSignaturesIntoPlugins, } from '@mui/x-charts/internals'; @@ -30,7 +28,6 @@ export type RadialLineChartPluginSignatures = [ UseChartHighlightSignature<'radialLine'>, UseChartVisibilityManagerSignature<'radialLine'>, UseChartKeyboardNavigationSignature, - UseChartItemClickSignature<'radialLine'>, UseChartProExportSignature, ]; @@ -43,6 +40,5 @@ export const RADIAL_LINE_CHART_PLUGINS: ConvertSignaturesIntoPlugins | null} highlightedItem The newly highlighted item. */ onHighlightChange: PropTypes.func, - /** - * The callback fired when an item is clicked. - * - * @param {React.MouseEvent} event The click event. - * @param {SeriesItemIdentifierWithType} item The clicked item. - */ - onItemClick: PropTypes.func, /** * The callback fired when the tooltip item changes. * From b548ae30635eb4f13a0dac887dc5d2aceedb3947 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 11:33:30 +0200 Subject: [PATCH 17/27] [charts] Simplify the keyboard focus plugin - 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. --- .../useChartKeyboardNavigation.ts | 40 ++++++++----------- .../useChartKeyboardNavigation.types.ts | 5 --- .../utils/getItemAtAxisPosition.ts | 15 ++----- 3 files changed, 20 insertions(+), 40 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 2225bb05b440f..fba0674da19d9 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -52,10 +52,6 @@ export const useChartKeyboardNavigation: ChartPlugin params.focusItemOnClick === true || store.state.keyboardNavigation.isFocusVisible, - ); - /** * Writes the focus state, only switching the highlight and tooltip to keyboard when visible. * An `undefined` item leaves the focused item untouched, `null` clears it. @@ -131,10 +127,6 @@ export const useChartKeyboardNavigation: ChartPlugin { - applyFocus(undefined, options); - }); - const focusItem = useEventCallback( (item: FocusedItemIdentifier, options?: FocusItemOptions) => { const seriesConfig = selectorChartSeriesConfig(store.state); @@ -177,7 +169,7 @@ export const useChartKeyboardNavigation: ChartPlugin { element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); element.removeEventListener('pointerdown', trackPointerIntent, true); - element.removeEventListener('click', focusChartOnClick); + document.removeEventListener('click', focusChartOnClick); document.removeEventListener('keydown', trackKeyboardIntent, true); }; }, [ @@ -300,8 +291,9 @@ export const useChartKeyboardNavigation: ChartPlugin { @@ -311,7 +303,7 @@ export const useChartKeyboardNavigation: ChartPlugin ({ diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts index da8f11940031c..cc33d5993b906 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.types.ts @@ -24,11 +24,6 @@ export interface UseChartKeyboardNavigationInstance { * @returns {boolean} `true` when the focus state was updated. */ focusItem: (item: FocusedItemIdentifier, options?: FocusItemOptions) => boolean; - /** - * Moves the DOM focus into the chart without changing the focused item. - * @param {FocusItemOptions} options Options to override the focus visibility. - */ - focusChart: (options?: FocusItemOptions) => void; } export interface UseChartKeyboardNavigationState { diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts index 35eff653b9b6a..071c021dcc47c 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts @@ -64,26 +64,19 @@ export function getItemAtAxisPosition({ } // The axis can be longer than the series it is shared with, and the index may be hidden. - const visibleDataIndex = + const findVisible = (direction: 1 | -1) => findVisibleDataIndex({ processedSeries, type: target.type, seriesId: target.seriesId, startIndex: Math.min(dataIndex, data.length - 1), dataLength: data.length, - direction: 1, - allowCycles: false, - }) ?? - findVisibleDataIndex({ - processedSeries, - type: target.type, - seriesId: target.seriesId, - startIndex: Math.min(dataIndex, data.length - 1), - dataLength: data.length, - direction: -1, + direction, allowCycles: false, }); + const visibleDataIndex = findVisible(1) ?? findVisible(-1); + if (visibleDataIndex === null) { return null; } From 2425de97589490005559d07d94270310020e42f0 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 11:41:06 +0200 Subject: [PATCH 18/27] [charts] Cover the click resolution against a controlled highlight 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. --- ...rtKeyboardNavigation.clickToFocus.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index c748f8c157f84..96202a0ccd45f 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { isJSDOM } from 'test/utils/skipIf'; import { createRenderer } from '@mui/internal-test-utils/createRenderer'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -236,6 +237,29 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(getFocusedDataIndex(container)).to.equal(1); }); + // The clicked item is resolved by the series, not read from the highlight. Controlling the + // highlight freezes `highlight.item`, so a focus derived from it would follow the consumer + // rather than the pointer. + it('resolves the clicked item independently of a controlled highlight', async () => { + function Controlled() { + const [highlightedItem, setHighlightedItem] = React.useState(null); + return ( + + ); + } + + const { container, user } = render(); + + await clickAt(user, container, getCenter(getBars(container)[1])); + await user.keyboard('[ArrowRight]'); + + expect(getFocusedDataIndex(container)).to.equal(2); + }); + it('fires onItemClick exactly once', async () => { const onItemClick = vi.fn(); const { container, user } = render(); From 89515c58eed5e19e06c9500536aa89f907d7f5c7 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 30 Jul 2026 12:43:24 +0200 Subject: [PATCH 19/27] [charts] Ignore a click that resolves to no item 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. --- .../data/charts/accessibility/accessibility.md | 2 +- ...artKeyboardNavigation.clickToFocus.test.tsx | 18 ++++++++++++++++++ .../useChartKeyboardNavigation.ts | 13 +++++++------ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index 7138a27478aac..256745661fb5f 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -83,7 +83,7 @@ Screen readers announce the clicked item right away. A click that lands on no item is resolved from the axis under the pointer, so clicking the empty space above a bar still focuses that bar's column. The item is taken from the series being navigated, or from the first series when none is focused yet. -Clicks outside of any axis only give the chart focus, keeping the item that was focused before. +Clicks that resolve to no item at all are ignored, so clicking next to a pie leaves the focus where it was. How the focus indicator behaves depends on how the user got there: diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 96202a0ccd45f..4ede29b4d50a6 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -426,6 +426,24 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(container.querySelector(`.${pieClasses.focusIndicator}`)).not.to.equal(null); }); + // The pie does not fill its drawing area, so a click next to it is not a click on the chart. + it.skipIf(isJSDOM)('ignores a click outside the pie', async () => { + const { container, user } = render(); + const layerContainer = getLayerContainer(container); + const rect = layerContainer.getBoundingClientRect(); + + await user.pointer([ + { + keys: '[MouseLeft]', + target: layerContainer, + coords: { clientX: rect.left + 2, clientY: rect.top + 2 }, + }, + ]); + + expect(container.contains(document.activeElement)).to.equal(false); + expect(container.querySelector(`.${pieClasses.focusIndicator}`)).to.equal(null); + }); + it('fires onItemClick exactly once', async () => { const onItemClick = vi.fn(); const { container, user } = render(); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index fba0674da19d9..7443170b5b150 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -151,7 +151,11 @@ export const useChartKeyboardNavigation: ChartPlugin { const element = chartsLayerContainerRef.current; @@ -168,12 +172,9 @@ export const useChartKeyboardNavigation: ChartPlugin { From 06271b6c976b7556a39667b77f67040b119eacc1 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 3 Aug 2026 18:18:27 +0200 Subject: [PATCH 20/27] [charts] Resolve the clicked item from the item under the pointer 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. --- .../charts/accessibility/accessibility.md | 23 ++--- .../BarChartPremium/RangeBar/RangeBarPlot.tsx | 16 ++-- .../x-charts-premium/src/Map/MapShape.tsx | 3 +- .../src/FunnelChart/FunnelSection.tsx | 3 +- .../src/SankeyChart/SankeyLinkElement.tsx | 7 +- .../src/SankeyChart/SankeyNodeElement.tsx | 7 +- packages/x-charts/src/BarChart/BarElement.tsx | 4 +- .../BarChart/useRegisterItemClickHandlers.ts | 18 +--- .../x-charts/src/LineChart/AreaElement.tsx | 3 +- packages/x-charts/src/LineChart/AreaPlot.tsx | 3 +- .../src/LineChart/CircleMarkElement.tsx | 10 +-- .../x-charts/src/LineChart/LineElement.tsx | 3 +- packages/x-charts/src/LineChart/LinePlot.tsx | 3 +- .../x-charts/src/LineChart/MarkElement.tsx | 10 +-- .../src/LineChart/useLineItemClickHandler.ts | 21 ++--- packages/x-charts/src/PieChart/PieArc.tsx | 6 +- .../RadarSeriesPlot/RadarSeriesArea.tsx | 33 +++++--- .../RadarSeriesPlot/RadarSeriesMarks.tsx | 20 +++-- .../RadarSeriesPlot/RadarSeriesPlot.tsx | 46 ++++++---- .../useInteractionAllItemProps.ts | 8 +- .../x-charts/src/ScatterChart/Scatter.tsx | 21 +++-- .../src/ScatterChart/ScatterMarker.tsx | 19 +---- .../async/ScatterAsyncBatch.test.ts | 56 ------------- .../ScatterChart/async/ScatterAsyncBatch.tsx | 58 +++---------- .../src/hooks/useInteractionItemProps.ts | 84 ++++++------------- .../shared/useRegisterPointerInteractions.ts | 10 +++ .../useChartClosestPoint.ts | 22 +++-- .../useChartClosestPoint.types.ts | 2 - .../useChartInteraction.ts | 42 +++++++++- .../useChartInteraction.types.ts | 20 +++++ .../useChartItemClick/useChartItemClick.ts | 12 +-- .../useChartItemClick.types.ts | 2 - .../useChartKeyboardNavigation.ts | 42 ++++------ 33 files changed, 278 insertions(+), 359 deletions(-) delete mode 100644 packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts diff --git a/docs/data/charts/accessibility/accessibility.md b/docs/data/charts/accessibility/accessibility.md index 256745661fb5f..fe1a2074f1fd0 100644 --- a/docs/data/charts/accessibility/accessibility.md +++ b/docs/data/charts/accessibility/accessibility.md @@ -137,44 +137,33 @@ import { FocusedSankeyLink, FocusedSankeyNode } from '@mui/x-charts-pro/SankeyCh ### Custom components -Custom components rendered through a slot receive an `onClick` handler that makes their item the one keyboard navigation resumes from. -Forward it to the rendered element, along with the rest of the props, for click-to-focus to work: +Click-to-focus reads the item the pointer is over, which every series already reports for the tooltip and the highlight. +A custom component rendered through a slot inherits that for free, as long as it forwards the interaction props to the rendered element: ```jsx function CustomMarker({ x, y, color, - size, seriesId, dataIndex, isHighlighted, isFaded, ...other }) { - // `other` carries the click handler and the interaction handlers. + // `other` carries the pointer handlers that report the item. return ( ); } ``` -The handler is attached whether or not you pass a click callback, so it can't tell you whether the chart is interactive. -Read the `cursor` prop instead of checking for `onClick`. - -When building a plot from scratch rather than through a slot, `useInteractionItemProps` returns the handlers for an item, including the click: +When building a plot from scratch rather than through a slot, `useInteractionItemProps` returns those handlers for an item: ```jsx import { useInteractionItemProps } from '@mui/x-charts/internals'; -function CustomPlotItem({ seriesId, dataIndex, onClick }) { - // Pass your own callback through the options: the returned `onClick` merges the two. - // Setting `onClick` on the element next to the spread makes them clobber each other. - const interactionProps = useInteractionItemProps( - { type: 'bar', seriesId, dataIndex }, - { onClick }, - ); - - return ; +function CustomPlotItem({ seriesId, dataIndex }) { + return ; } ``` diff --git a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx index 607fc405db7b8..d8761ee806f11 100644 --- a/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx +++ b/packages/x-charts-premium/src/BarChartPremium/RangeBar/RangeBarPlot.tsx @@ -2,8 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; -import { useSkipAnimation, useChartsContext } from '@mui/x-charts/internals'; -import type { UseChartKeyboardNavigationSignature } from '@mui/x-charts/internals'; +import { useSkipAnimation } from '@mui/x-charts/internals'; import { BarElement, barClasses } from '@mui/x-charts/BarChart'; import type { BarElementSlotProps, @@ -100,7 +99,6 @@ function RangeBarSvgPlot(props: Omit): React.JSX. const completedData = useRangeBarPlotData(useDrawingArea(), xAxes, yAxes); const classes = useUtilityClasses(); - const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); const slots: BarElementSlots = { ...props.slots, bar: props.slots?.bar ?? AnimatedRangeBarElement, @@ -131,12 +129,12 @@ function RangeBarSvgPlot(props: Omit): React.JSX. ry={borderRadius} {...other} slots={slots} - cursor={onItemClick ? 'pointer' : undefined} - onClick={(event) => { - const identifier = { type: 'rangeBar', seriesId, dataIndex } as const; - instance.focusItem?.(identifier); - onItemClick?.(event, identifier); - }} + onClick={ + onItemClick && + ((event) => { + onItemClick(event, { type: 'rangeBar', seriesId, dataIndex }); + }) + } /> ); })} diff --git a/packages/x-charts-premium/src/Map/MapShape.tsx b/packages/x-charts-premium/src/Map/MapShape.tsx index e5ef0eb7f2a10..dc0f1f8148ef4 100644 --- a/packages/x-charts-premium/src/Map/MapShape.tsx +++ b/packages/x-charts-premium/src/Map/MapShape.tsx @@ -29,7 +29,7 @@ function MapShape(props: MapShapeProps) { () => ({ type: 'mapShape' as const, seriesId, name: featureName }), [seriesId, featureName], ); - const interactionProps = useInteractionItemProps(identifier, { onClick }); + const interactionProps = useInteractionItemProps(identifier); const highlightState = useItemHighlightState(identifier); const isHighlighted = highlightState === 'highlighted'; const isFaded = highlightState === 'faded'; @@ -38,6 +38,7 @@ function MapShape(props: MapShapeProps) { ( [seriesId, dataIndex], ); - const interactionProps = useInteractionItemProps(identifier, { onClick }); + const interactionProps = useInteractionItemProps(identifier); const highlightState = useItemHighlightState(identifier); const isHighlighted = highlightState === 'highlighted'; const isFaded = highlightState === 'faded'; @@ -71,6 +71,7 @@ const FunnelSection = consumeSlots( strokeOpacity={1} strokeWidth={isOutlined ? 1.5 : 0} cursor={onClick ? 'pointer' : 'unset'} + onClick={onClick} data-highlighted={isHighlighted || undefined} data-faded={isFaded || undefined} className={clsx(classes?.section, className)} diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx index 1331d9f0f0211..42e0dae628642 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyLinkElement.tsx @@ -51,15 +51,15 @@ export const SankeyLinkElement = React.forwardRef) => { onClick?.(event, identifier); }); - // Add interaction props for tooltips and keyboard focus - const interactionProps = useInteractionItemProps(identifier, { onClick: handleClick }); - if (!link.path) { return null; } @@ -82,6 +82,7 @@ export const SankeyLinkElement = React.forwardRef diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx index 54bb755b92277..1253212245af2 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyNodeElement.tsx @@ -54,15 +54,15 @@ export const SankeyNodeElement = React.forwardRef) => { onClick?.(event, identifier); }); - // Add interaction props for tooltips and keyboard focus - const interactionProps = useInteractionItemProps(identifier, { onClick: handleClick }); - let opacity = 1; if (isFaded) { opacity = 0.3; @@ -78,6 +78,7 @@ export const SankeyNodeElement = React.forwardRef, - UseChartTooltipSignature, - UseChartKeyboardNavigationSignature, - ] + [UseChartInteractionSignature, UseChartHighlightSignature<'bar'>, UseChartTooltipSignature] >(); const chartsLayerContainerRef = useChartsLayerContainerRef(); const store = useStore<[UseChartCartesianAxisSignature, UseChartHighlightSignature<'bar'>]>(); @@ -34,8 +28,7 @@ export function useRegisterItemClickHandlers( React.useEffect(() => { const element = chartsLayerContainerRef.current; - // The handlers also feed the keyboard navigation, so they are registered without `onItemClick`. - if (!element) { + if (!element || !onItemClick) { return undefined; } @@ -73,14 +66,11 @@ export function useRegisterItemClickHandlers( const item = selectorBarItemAtPosition(store.state, svgPoint); if (item) { - const identifier = { + onItemClick(event, { type: 'bar', seriesId: item.seriesId, dataIndex: item.dataIndex, - } as const; - - instance.focusItem?.(identifier); - onItemClick?.(event, identifier); + }); } }; diff --git a/packages/x-charts/src/LineChart/AreaElement.tsx b/packages/x-charts/src/LineChart/AreaElement.tsx index 8c93a304fe600..407ae5df8687d 100644 --- a/packages/x-charts/src/LineChart/AreaElement.tsx +++ b/packages/x-charts/src/LineChart/AreaElement.tsx @@ -72,7 +72,6 @@ function AreaElement(props: AreaElementProps) { slots, slotProps, onClick, - cursor, ...other } = props; @@ -104,7 +103,7 @@ function AreaElement(props: AreaElementProps) { additionalProps: { ...(enablePositionBasedPointerInteraction ? {} : interactionProps), onClick, - cursor: cursor ?? (onClick ? 'pointer' : 'unset'), + cursor: onClick ? 'pointer' : 'unset', 'data-highlighted': isHighlighted || undefined, 'data-faded': isFaded || undefined, 'data-series-id': seriesId, diff --git a/packages/x-charts/src/LineChart/AreaPlot.tsx b/packages/x-charts/src/LineChart/AreaPlot.tsx index 321c9cae3b588..307035f51e2be 100644 --- a/packages/x-charts/src/LineChart/AreaPlot.tsx +++ b/packages/x-charts/src/LineChart/AreaPlot.tsx @@ -91,8 +91,7 @@ function AreaPlot(props: AreaPlotProps) { gradientId={gradientId} slots={slots} slotProps={slotProps} - onClick={(event) => onAreaItemClick(event, seriesId)} - cursor={onItemClick ? 'pointer' : 'unset'} + onClick={onAreaItemClick && ((event) => onAreaItemClick(event, seriesId))} skipAnimation={skipAnimation} /> ), diff --git a/packages/x-charts/src/LineChart/CircleMarkElement.tsx b/packages/x-charts/src/LineChart/CircleMarkElement.tsx index 52af81588e1fb..8dd4968dd37c0 100644 --- a/packages/x-charts/src/LineChart/CircleMarkElement.tsx +++ b/packages/x-charts/src/LineChart/CircleMarkElement.tsx @@ -83,11 +83,7 @@ function CircleMarkElement(props: CircleMarkElementProps) { selectorChartExperimentalFeaturesState, 'enablePositionBasedPointerInteraction', ); - // The click keeps focusing the item even when the pointer interactions are position based. - const { onClick: handleClick, ...pointerProps } = useInteractionItemProps( - { type: 'line', seriesId, dataIndex }, - { onClick }, - ); + const interactionProps = useInteractionItemProps({ type: 'line', seriesId, dataIndex }); const theme = useTheme(); const classes = useLineUtilityClasses({ skipAnimation, classes: innerClasses }); @@ -95,7 +91,7 @@ function CircleMarkElement(props: CircleMarkElementProps) { return ( onLineItemClick(event, seriesId)} - cursor={onItemClick ? 'pointer' : 'unset'} + onClick={onLineItemClick && ((event) => onLineItemClick(event, seriesId))} /> ); })} diff --git a/packages/x-charts/src/LineChart/MarkElement.tsx b/packages/x-charts/src/LineChart/MarkElement.tsx index 79544848ff688..486ab6be58450 100644 --- a/packages/x-charts/src/LineChart/MarkElement.tsx +++ b/packages/x-charts/src/LineChart/MarkElement.tsx @@ -97,11 +97,7 @@ function MarkElement(props: MarkElementProps) { selectorChartExperimentalFeaturesState, 'enablePositionBasedPointerInteraction', ); - // The click keeps focusing the item even when the pointer interactions are position based. - const { onClick: handleClick, ...pointerProps } = useInteractionItemProps( - { type: 'line', seriesId, dataIndex }, - { onClick }, - ); + const interactionProps = useInteractionItemProps({ type: 'line', seriesId, dataIndex }); const ownerState = { seriesId, @@ -115,7 +111,7 @@ function MarkElement(props: MarkElementProps) { return ( , lineItemIdentifier: LineItemClickIdentifier, ) => void, -): (event: React.MouseEvent, seriesId: SeriesId) => void { +): ((event: React.MouseEvent, seriesId: SeriesId) => void) | undefined { const chartsLayerContainerRef = useChartsLayerContainerRef(); const { xAxis: xAxes, xAxisIds } = useXAxes(); const seriesData = useLineSeriesContext(); const defaultXAxisId = xAxisIds[0]; - const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); return React.useMemo(() => { + if (!onItemClick) { + return undefined; + } + return (event: React.MouseEvent, seriesId: SeriesId) => { const element = chartsLayerContainerRef.current; const xAxisId = seriesData?.series[seriesId]?.xAxisId ?? defaultXAxisId; @@ -47,10 +47,7 @@ export function useLineItemClickHandler( return; } - const identifier = { type: 'line', seriesId, dataIndex } as const; - - instance.focusItem?.(identifier); - onItemClick?.(event, identifier); + onItemClick(event, { type: 'line', seriesId, dataIndex }); }; - }, [onItemClick, chartsLayerContainerRef, seriesData, defaultXAxisId, xAxes, instance]); + }, [onItemClick, chartsLayerContainerRef, seriesData, defaultXAxisId, xAxes]); } diff --git a/packages/x-charts/src/PieChart/PieArc.tsx b/packages/x-charts/src/PieChart/PieArc.tsx index a7d910c2f8cb3..3008ef060d460 100644 --- a/packages/x-charts/src/PieChart/PieArc.tsx +++ b/packages/x-charts/src/PieChart/PieArc.tsx @@ -75,10 +75,7 @@ const PieArc = React.forwardRef(function PieArc(pro }; const classes = usePieUtilityClasses(ownerState); - const interactionProps = useInteractionItemProps( - { type: 'pie', seriesId, dataIndex }, - { onClick }, - ); + const interactionProps = useInteractionItemProps({ type: 'pie', seriesId, dataIndex }); const animatedProps = useAnimatePieArc({ cornerRadius, startAngle, @@ -92,6 +89,7 @@ const PieArc = React.forwardRef(function PieArc(pro return ( (); const getHighlightState = useItemHighlightStateGetter<'radar'>(); - const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); + + /** + * The radar area only knows its series, so the pointer item is resolved from the angle. Also + * bound to `pointerdown`, because a touch tap may never produce a `pointermove`. + */ + const handlePointerItem = (seriesId: SeriesId) => (event: React.PointerEvent) => { + instance.setHoveredItem?.({ + type: 'radar', + seriesId, + dataIndex: getRotationIndex(event), + }); + }; const classes = useUtilityClasses(inClasses); return ( @@ -70,18 +82,17 @@ function RadarSeriesArea(props: RadarSeriesAreaProps) { getHighlightState, classes, })} - cursor={onItemClick ? 'pointer' : 'unset'} - {...interactionProps[seriesIndex]} - // The area only knows its series, the data index comes from the click angle. - onClick={(event) => { - const identifier = { + onClick={(event) => + onItemClick?.(event, { type: 'radar', seriesId: id, dataIndex: getRotationIndex(event), - } as const; - instance.focusItem?.(identifier); - onItemClick?.(event, identifier); - }} + }) + } + cursor={onItemClick ? 'pointer' : 'unset'} + {...interactionProps[seriesIndex]} + onPointerMove={handlePointerItem(id)} + onPointerDown={handlePointerItem(id)} {...other} /> ); diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx index 4d8d38344baf8..f416a92b2e7f0 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx @@ -9,7 +9,7 @@ import type { SeriesId } from '../../models/seriesType/common'; import type { HighlightItemIdentifierWithType } from '../../models'; import type { HighlightState } from '../../hooks/useItemHighlightState'; import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; -import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; +import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; interface GetCirclePropsParams { seriesId: SeriesId; @@ -45,7 +45,7 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { const classes = useUtilityClasses(inClasses); const getHighlightState = useItemHighlightStateGetter(); - const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); + const { instance } = useChartsContext<[UseChartInteractionSignature]>(); return ( @@ -68,11 +68,17 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { classes, })} pointerEvents={onItemClick ? undefined : 'none'} - onClick={(event) => { - const identifier = { type: 'radar', seriesId: id, dataIndex: index } as const; - instance.focusItem?.(identifier); - onItemClick?.(event, identifier); - }} + // Marks only take pointer events when clickable, and then they hide the area + // underneath, so they have to report the pointer item themselves. + onPointerMove={() => + instance.setHoveredItem?.({ type: 'radar', seriesId: id, dataIndex: index }) + } + onPointerDown={() => + instance.setHoveredItem?.({ type: 'radar', seriesId: id, dataIndex: index }) + } + onClick={(event) => + onItemClick?.(event, { type: 'radar', seriesId: id, dataIndex: index }) + } cursor={onItemClick ? 'pointer' : 'unset'} {...other} /> diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx index 61ca8378ab7a6..3772e87b171df 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx @@ -9,7 +9,8 @@ import { getPathProps } from './RadarSeriesArea'; import { getCircleProps } from './RadarSeriesMarks'; import { useRadarRotationIndex } from './useRadarRotationIndex'; import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; -import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; +import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; +import type { SeriesId } from '../../models/seriesType'; function RadarSeriesPlot(props: RadarSeriesPlotProps) { const { seriesId: inSeriesId, className, classes: inClasses, onAreaClick, onMarkClick } = props; @@ -17,8 +18,20 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { const getRotationIndex = useRadarRotationIndex(); const interactionProps = useInteractionAllItemProps(seriesCoordinates); + const { instance } = useChartsContext<[UseChartInteractionSignature]>(); const getHighlightState = useItemHighlightStateGetter(); - const { instance } = useChartsContext<[UseChartKeyboardNavigationSignature]>(); + + /** + * The radar area only knows its series, so the pointer item is resolved from the angle. Also + * bound to `pointerdown`, because a touch tap may never produce a `pointermove`. + */ + const handlePointerItem = (seriesId: SeriesId) => (event: React.PointerEvent) => { + instance.setHoveredItem?.({ + type: 'radar', + seriesId, + dataIndex: getRotationIndex(event), + }); + }; const classes = useUtilityClasses(inClasses); @@ -43,18 +56,17 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { getHighlightState, classes, })} - {...interactionProps[seriesIndex]} - // The area only knows its series, the data index comes from the click angle. - onClick={(event) => { - const identifier = { + onClick={(event) => + onAreaClick?.(event, { type: 'radar', seriesId, dataIndex: getRotationIndex(event), - } as const; - instance.focusItem?.(identifier); - onAreaClick?.(event, identifier); - }} + }) + } cursor={onAreaClick ? 'pointer' : 'unset'} + {...interactionProps[seriesIndex]} + onPointerMove={handlePointerItem(seriesId)} + onPointerDown={handlePointerItem(seriesId)} /> } {!hideMark && @@ -69,12 +81,16 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { getHighlightState, classes, })} - onClick={(event) => { - const identifier = { type: 'radar', seriesId, dataIndex: index } as const; - instance.focusItem?.(identifier); - onMarkClick?.(event, identifier); - }} + onClick={(event) => + onMarkClick?.(event, { type: 'radar', seriesId, dataIndex: index }) + } cursor={onMarkClick ? 'pointer' : 'unset'} + onPointerMove={() => + instance.setHoveredItem?.({ type: 'radar', seriesId, dataIndex: index }) + } + onPointerDown={() => + instance.setHoveredItem?.({ type: 'radar', seriesId, dataIndex: index }) + } /> ))} diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts b/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts index 08316c17eabfc..5c15068920f4a 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/useInteractionAllItemProps.ts @@ -2,7 +2,6 @@ import * as React from 'react'; import type { SeriesItemIdentifierWithData } from '../../models/seriesType'; import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; import type { UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight'; -import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartTooltipSignature } from '../../internals/plugins/featurePlugins/useChartTooltip'; import { getInteractionItemProps } from '../../hooks/useInteractionItemProps'; @@ -13,12 +12,7 @@ export const useInteractionAllItemProps = ( ) => { const { instance } = useChartsContext< - [ - UseChartInteractionSignature, - UseChartHighlightSignature<'radar'>, - UseChartTooltipSignature, - UseChartKeyboardNavigationSignature, - ] + [UseChartInteractionSignature, UseChartHighlightSignature<'radar'>, UseChartTooltipSignature] >(); const results = React.useMemo(() => { diff --git a/packages/x-charts/src/ScatterChart/Scatter.tsx b/packages/x-charts/src/ScatterChart/Scatter.tsx index b8a537ea9e4cb..3e289eb49526f 100644 --- a/packages/x-charts/src/ScatterChart/Scatter.tsx +++ b/packages/x-charts/src/ScatterChart/Scatter.tsx @@ -24,7 +24,6 @@ import { useChartsContext } from '../context/ChartsProvider'; import type { UseChartTooltipSignature } from '../internals/plugins/featurePlugins/useChartTooltip'; import type { UseChartInteractionSignature } from '../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartHighlightSignature } from '../internals/plugins/featurePlugins/useChartHighlight'; -import type { UseChartKeyboardNavigationSignature } from '../internals/plugins/featurePlugins/useChartKeyboardNavigation'; /** * @deprecated The `Scatter` component is an internal implementation detail of `ScatterPlot` and will be removed from the public API in v10. Use `ScatterPlot` instead. @@ -99,7 +98,6 @@ function Scatter(props: ScatterProps) { UseChartInteractionSignature, UseChartHighlightSignature<'scatter'>, UseChartTooltipSignature, - UseChartKeyboardNavigationSignature, ] >(); const store = useStore<[UseChartClosestPointSignature]>(); @@ -140,19 +138,20 @@ function Scatter(props: ScatterProps) { isFaded={isItemFaded} x={dataPoint.x} y={dataPoint.y} - cursor={onItemClick ? 'pointer' : 'unset'} + onClick={ + onItemClick && + ((event) => + onItemClick(event, { + type: 'scatter', + seriesId: series.id, + dataIndex: dataPoint.dataIndex, + })) + } data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} {...(skipInteractionHandlers ? undefined - : getInteractionItemProps(instance, dataPoint, { - onClick: (event) => - onItemClick?.(event, { - type: 'scatter', - seriesId: series.id, - dataIndex: dataPoint.dataIndex, - }), - }))} + : getInteractionItemProps(instance, dataPoint))} {...markerProps} /> ); diff --git a/packages/x-charts/src/ScatterChart/ScatterMarker.tsx b/packages/x-charts/src/ScatterChart/ScatterMarker.tsx index c4247f5f5c382..70ba1c1163dcd 100644 --- a/packages/x-charts/src/ScatterChart/ScatterMarker.tsx +++ b/packages/x-charts/src/ScatterChart/ScatterMarker.tsx @@ -37,21 +37,13 @@ export interface ScatterMarkerProps { isFaded: boolean; /** * Callback fired when clicking on a scatter item. - * It is always defined: besides `onItemClick`, it makes the item the one keyboard navigation - * resumes from. Use `cursor` to tell whether the chart is interactive. * @param {MouseEvent} event Mouse event recorded on the `` element. */ onClick?: (event: React.MouseEvent) => void; - /** - * The cursor to display over the marker. - * @default 'unset' - */ - cursor?: React.SVGAttributes['cursor']; } function ScatterMarker(props: ScatterMarkerProps) { - const { seriesId, isFaded, isHighlighted, x, y, color, size, dataIndex, cursor, ...other } = - props; + const { seriesId, isFaded, isHighlighted, x, y, color, size, dataIndex, ...other } = props; return ( ); @@ -76,11 +68,6 @@ ScatterMarker.propTypes /* remove-proptypes */ = { * The fill color of the marker. */ color: PropTypes.string.isRequired, - /** - * The cursor to display over the marker. - * @default 'unset' - */ - cursor: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** * The index of the data point. */ @@ -95,8 +82,6 @@ ScatterMarker.propTypes /* remove-proptypes */ = { isHighlighted: PropTypes.bool.isRequired, /** * Callback fired when clicking on a scatter item. - * It is always defined: besides `onItemClick`, it makes the item the one keyboard navigation - * resumes from. Use `cursor` to tell whether the chart is interactive. * @param {MouseEvent} event Mouse event recorded on the `` element. */ onClick: PropTypes.func, diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts deleted file mode 100644 index a4b7d810d40b4..0000000000000 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { vi } from 'vitest'; -import { getMarkerInteractionProps } from './ScatterAsyncBatch'; - -const instance = { - setLastUpdateSource: () => {}, - setTooltipItem: () => {}, - setHighlight: () => {}, - removeTooltipItem: () => {}, - clearHighlight: () => {}, - focusItem: vi.fn(), -} as any; - -const dataPoint = { x: 0, y: 0, dataIndex: 3, seriesId: 'A', type: 'scatter' } as any; - -const build = (overrides: Record) => - getMarkerInteractionProps({ - instance, - dataPoint, - seriesId: 'A', - dataIndex: 3, - onItemClick: undefined, - skipInteractionHandlers: false, - isInteracting: false, - ...overrides, - } as any); - -describe('getMarkerInteractionProps', () => { - it('returns the pointer handlers and the click by default', () => { - const props = build({})!; - expect(props.onClick).not.to.equal(undefined); - expect(props.onPointerEnter).not.to.equal(undefined); - }); - - it('keeps the click while a zoom interaction is in progress', () => { - const props = build({ isInteracting: true })!; - // Pointer handlers are dropped for performance, the click still fires the callback. - expect(props.onClick).not.to.equal(undefined); - expect(props.onPointerEnter).to.equal(undefined); - }); - - it('forwards onItemClick with the item identifier', () => { - const onItemClick = vi.fn(); - build({ onItemClick, isInteracting: true })!.onClick!({} as any); - - expect(onItemClick.mock.calls.length).to.equal(1); - expect(onItemClick.mock.lastCall?.[1]).to.deep.equal({ - type: 'scatter', - seriesId: 'A', - dataIndex: 3, - }); - }); - - it('drops every handler when the closest point plugin owns the series', () => { - expect(build({ skipInteractionHandlers: true })).to.equal(undefined); - }); -}); diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index 3a868cfc6ff81..285ecdfaa9bd9 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -15,9 +15,7 @@ import { useChartsContext } from '../../context/ChartsProvider'; import type { UseChartTooltipSignature } from '../../internals/plugins/featurePlugins/useChartTooltip'; import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; import type { UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight'; -import type { UseChartKeyboardNavigationSignature } from '../../internals/plugins/featurePlugins/useChartKeyboardNavigation'; import type { ScatterProps } from '../Scatter'; -import type { SeriesId } from '../../models/seriesType/common'; import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; export interface ScatterAsyncBatchProps extends Pick< @@ -43,39 +41,6 @@ export interface ScatterAsyncBatchProps extends Pick< isInteracting?: boolean; } -/** - * Pointer handlers are dropped mid zoom, where per-marker highlight is useless and the dominant - * per-frame cost. The click is kept: it drives `onItemClick` and the keyboard focus, and neither - * is on the drag path. Both are dropped when the closest point plugin handles the series instead. - */ -export function getMarkerInteractionProps({ - instance, - dataPoint, - seriesId, - dataIndex, - onItemClick, - skipInteractionHandlers, - isInteracting, -}: { - instance: Parameters>[0]; - dataPoint: Parameters>[1]; - seriesId: SeriesId; - dataIndex: number; - onItemClick: ScatterProps['onItemClick']; - skipInteractionHandlers: boolean | undefined; - isInteracting?: boolean; -}) { - if (skipInteractionHandlers) { - return undefined; - } - - const { onClick, ...pointerProps } = getInteractionItemProps(instance, dataPoint, { - onClick: (event) => onItemClick?.(event, { type: 'scatter', seriesId, dataIndex }), - }); - - return isInteracting ? { onClick } : { onClick, ...pointerProps }; -} - /** * @ignore - internal component. */ @@ -101,7 +66,6 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { UseChartInteractionSignature, UseChartHighlightSignature<'scatter'>, UseChartTooltipSignature, - UseChartKeyboardNavigationSignature, ] >(); const store = useStore<[UseChartClosestPointSignature]>(); @@ -153,18 +117,20 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { isFaded={isItemFaded} x={x} y={y} - cursor={onItemClick ? 'pointer' : 'unset'} + onClick={ + onItemClick && + ((event: React.MouseEvent) => + onItemClick(event, { + type: 'scatter', + seriesId: series.id, + dataIndex, + })) + } data-highlighted={isItemHighlighted || undefined} data-faded={isItemFaded || undefined} - {...getMarkerInteractionProps({ - instance, - dataPoint, - seriesId: series.id, - dataIndex, - onItemClick, - skipInteractionHandlers, - isInteracting, - })} + {...(skipInteractionHandlers || isInteracting + ? undefined + : getInteractionItemProps(instance, dataPoint))} {...markerProps} />, ); diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 36a55b260b516..22b6409bf8540 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -5,32 +5,9 @@ import { useChartsContext } from '../context/ChartsProvider'; import type { UseChartHighlightSignature } from '../internals/plugins/featurePlugins/useChartHighlight'; import type { UseChartInteractionSignature } from '../internals/plugins/featurePlugins/useChartInteraction'; import type { ChartSeriesType } from '../models/seriesType/config'; -import type { FocusedItemIdentifier, SeriesItemIdentifierWithType } from '../models/seriesType'; +import type { SeriesItemIdentifierWithType } from '../models/seriesType'; import type { ChartInstance } from '../internals/plugins/models'; import type { UseChartTooltipSignature } from '../internals/plugins/featurePlugins/useChartTooltip'; -import type { UseChartKeyboardNavigationSignature } from '../internals/plugins/featurePlugins/useChartKeyboardNavigation'; - -type InteractionItemSignatures = [ - UseChartInteractionSignature, - UseChartHighlightSignature, - UseChartTooltipSignature, - UseChartKeyboardNavigationSignature, -]; - -export interface InteractionItemOptions { - /** - * Click handler of the consumer, called after the item took the keyboard focus. - * @param {React.MouseEvent} event The click event. - */ - onClick?: (event: React.MouseEvent) => void; -} - -export interface InteractionItemProps { - onPointerEnter?: () => void; - onPointerLeave?: () => void; - onPointerDown?: (event: React.PointerEvent) => void; - onClick?: (event: React.MouseEvent) => void; -} function onPointerDown(event: React.PointerEvent) { if ( @@ -41,38 +18,37 @@ function onPointerDown(event: React.PointerEvent) { } } -/** - * Props to make a series item interactive: pointer handlers driving the highlight and the - * tooltip, and a click that makes the item the one keyboard navigation resumes from. - * - * The returned `onClick` merges `options.onClick`, so pass the consumer callback through the - * options rather than setting `onClick` on the element. Spreading these props next to a separate - * `onClick` makes the two clobber each other depending on the spread order. - */ export const useInteractionItemProps = ( data: SeriesItemIdentifierWithType, - options?: InteractionItemOptions, -): InteractionItemProps => { - const { instance } = useChartsContext>(); +): { + onPointerEnter?: () => void; + onPointerLeave?: () => void; + onPointerDown?: (event: React.PointerEvent) => void; +} => { + const { instance } = + useChartsContext< + [ + UseChartInteractionSignature, + UseChartHighlightSignature, + UseChartTooltipSignature, + ] + >(); const interactionActive = React.useRef(false); const onPointerEnter = useEventCallback(() => { interactionActive.current = true; instance.setLastUpdateSource('pointer'); + instance.setHoveredItem(data); instance.setTooltipItem(data); instance.setHighlight(data); }); const onPointerLeave = useEventCallback(() => { interactionActive.current = false; + instance.clearHoveredItem(data); instance.removeTooltipItem(data); instance.clearHighlight(); }); - const onClick = useEventCallback((event: React.MouseEvent) => { - instance.focusItem?.(data as unknown as FocusedItemIdentifier); - options?.onClick?.(event); - }); - React.useEffect(() => { return () => { /* Clean up state if this item is unmounted while active. */ @@ -87,26 +63,27 @@ export const useInteractionItemProps = ( onPointerEnter, onPointerLeave, onPointerDown, - onClick, }), - [onPointerEnter, onPointerLeave, onClick], + [onPointerEnter, onPointerLeave], ); }; -/** - * Loop friendly variant of `useInteractionItemProps`, for series rendering many items. - * The same merge rule applies to `options.onClick`. - */ export function getInteractionItemProps( - instance: ChartInstance>, + instance: ChartInstance< + [UseChartInteractionSignature, UseChartHighlightSignature, UseChartTooltipSignature] + >, item: SeriesItemIdentifierWithType, - options?: InteractionItemOptions, -): InteractionItemProps { +): { + onPointerEnter?: () => void; + onPointerLeave?: () => void; + onPointerDown?: (event: React.PointerEvent) => void; +} { function onPointerEnter() { if (!item) { return; } instance.setLastUpdateSource('pointer'); + instance.setHoveredItem(item); instance.setTooltipItem(item); instance.setHighlight(item); } @@ -115,21 +92,14 @@ export function getInteractionItemProps( if (!item) { return; } + instance.clearHoveredItem(item); instance.removeTooltipItem(item); instance.clearHighlight(); } - function onClick(event: React.MouseEvent) { - if (item) { - instance.focusItem?.(item as unknown as FocusedItemIdentifier); - } - options?.onClick?.(event); - } - return { onPointerEnter, onPointerLeave, onPointerDown, - onClick, }; } diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/shared/useRegisterPointerInteractions.ts b/packages/x-charts/src/internals/plugins/featurePlugins/shared/useRegisterPointerInteractions.ts index 808e316e18743..1578d6c45bba0 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/shared/useRegisterPointerInteractions.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/shared/useRegisterPointerInteractions.ts @@ -66,6 +66,7 @@ export function useRegisterPointerInteractions() { if (lastItem) { lastItemRef.current = undefined; + instance.clearHoveredItem(lastItem); instance.removeTooltipItem(lastItem); instance.clearHighlight(); } @@ -101,6 +102,7 @@ export function useRegisterPointerInteractions() { if (item) { instance.setLastUpdateSource('pointer'); + instance.setHoveredItem(item); instance.setTooltipItem(item); instance.setHighlight(item); lastItemRef.current = item; @@ -109,11 +111,19 @@ export function useRegisterPointerInteractions() { } }; + // Touch has no hover phase, so a tap may never produce a `pointermove`. Resolving on + // `pointerdown` too is what makes the pointer item known for a stationary tap. + const onPointerDown = function onPointerDown(event: PointerEvent) { + onPointerMove(event); + }; + + element.addEventListener('pointerdown', onPointerDown); element.addEventListener('pointerleave', onPointerLeave); element.addEventListener('pointermove', onPointerMove); element.addEventListener('pointerenter', onPointerEnter); return () => { + element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('pointerenter', onPointerEnter); element.removeEventListener('pointermove', onPointerMove); element.removeEventListener('pointerleave', onPointerLeave); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts index 6c83755612f3f..4ef0444570465 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts @@ -175,6 +175,7 @@ export const useChartClosestPoint: ChartPlugin = const moveEndHandler = instance.addInteractionListener('moveEnd', (event) => { if (!event.detail.activeGestures.pan) { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); } @@ -182,6 +183,7 @@ export const useChartClosestPoint: ChartPlugin = const panEndHandler = instance.addInteractionListener('panEnd', (event) => { if (!event.detail.activeGestures.move) { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); } @@ -189,6 +191,7 @@ export const useChartClosestPoint: ChartPlugin = const pressEndHandler = instance.addInteractionListener('quickPressEnd', (event) => { if (!event.detail.activeGestures.move && !event.detail.activeGestures.pan) { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); } @@ -199,6 +202,7 @@ export const useChartClosestPoint: ChartPlugin = if (closestPoint === 'outside-chart') { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); return; @@ -206,12 +210,14 @@ export const useChartClosestPoint: ChartPlugin = if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { instance.removeTooltipItem?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); return; } const { seriesId, dataIndex } = closestPoint; + instance.setHoveredItem?.({ type: 'scatter', seriesId, dataIndex }); instance.setTooltipItem?.({ type: 'scatter', seriesId, dataIndex }); instance.setLastUpdateSource?.('pointer'); instance.setHighlight?.({ @@ -221,16 +227,19 @@ export const useChartClosestPoint: ChartPlugin = }); }; + // A tap resolves nothing without a preceding move, and touch has no hover phase, so the + // pointer item is also resolved on `pointerdown`. + const onPointerDown = function onPointerDown(event: PointerEvent) { + gestureHandler({ detail: { srcEvent: event } } as any); + }; + element.addEventListener('pointerdown', onPointerDown); + const tapHandler = instance.addInteractionListener('tap', (event) => { const closestPoint = getClosestPoint(event.detail.srcEvent); - if (typeof closestPoint !== 'string') { + if (typeof closestPoint !== 'string' && onItemClick) { const { seriesId, dataIndex } = closestPoint; - const identifier = { type: 'scatter', seriesId, dataIndex } as const; - - // Clicking the empty space next to a point focuses that point. - instance.focusItem?.(identifier); - onItemClick?.(event.detail.srcEvent, identifier); + onItemClick(event.detail.srcEvent, { type: 'scatter', seriesId, dataIndex }); } }); @@ -239,6 +248,7 @@ export const useChartClosestPoint: ChartPlugin = const pressHandler = instance.addInteractionListener('quickPress', gestureHandler); return () => { + element.removeEventListener('pointerdown', onPointerDown); tapHandler.cleanup(); moveHandler.cleanup(); moveEndHandler.cleanup(); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts index b4300842e7a12..60268d3284dc5 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.types.ts @@ -7,7 +7,6 @@ import type { UseChartHighlightSignature } from '../useChartHighlight'; import type { UseChartInteractionSignature } from '../useChartInteraction'; import type { UseChartTooltipSignature } from '../useChartTooltip'; import type { UseChartZAxisSignature } from '../useChartZAxis'; -import type { UseChartKeyboardNavigationSignature } from '../useChartKeyboardNavigation'; export interface UseChartVoronoiInstance { /** @@ -67,6 +66,5 @@ export type UseChartClosestPointSignature, UseChartTooltipSignature, UseChartZAxisSignature, - UseChartKeyboardNavigationSignature, ]; }>; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.ts index 9e740653543bd..e916c6e58739e 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.ts @@ -1,10 +1,13 @@ import useEventCallback from '@mui/utils/useEventCallback'; +import { fastObjectShallowCompare } from '@mui/x-internals/fastObjectShallowCompare'; import type { ChartPlugin } from '../../models'; import type { Coordinate, InteractionUpdateSource, UseChartInteractionSignature, } from './useChartInteraction.types'; +import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; +import type { ChartSeriesType } from '../../../../models/seriesType/config'; export const useChartInteraction: ChartPlugin = ({ store }) => { const cleanInteraction = useEventCallback(function cleanInteraction() { @@ -21,6 +24,35 @@ export const useChartInteraction: ChartPlugin = ({ } }); + const setHoveredItem = useEventCallback(function setHoveredItem( + item: SeriesItemIdentifierWithType, + ) { + const previousItem = store.state.interaction.hoveredItem; + + if (previousItem !== null && fastObjectShallowCompare(previousItem, item)) { + return; + } + + store.set('interaction', { ...store.state.interaction, hoveredItem: item }); + }); + + const clearHoveredItem = useEventCallback(function clearHoveredItem( + itemToRemove?: SeriesItemIdentifierWithType, + ) { + const previousItem = store.state.interaction.hoveredItem; + + if (previousItem === null) { + return; + } + + if (itemToRemove !== undefined && !fastObjectShallowCompare(previousItem, itemToRemove)) { + // Another item took over already. + return; + } + + store.set('interaction', { ...store.state.interaction, hoveredItem: null }); + }); + const setPointerCoordinate = useEventCallback(function setPointerCoordinate( coordinate: Coordinate | null, ) { @@ -50,6 +82,8 @@ export const useChartInteraction: ChartPlugin = ({ instance: { cleanInteraction, setLastUpdateSource, + setHoveredItem, + clearHoveredItem, setPointerCoordinate, handlePointerEnter, handlePointerLeave, @@ -58,7 +92,13 @@ export const useChartInteraction: ChartPlugin = ({ }; useChartInteraction.getInitialState = () => ({ - interaction: { item: null, pointer: null, lastUpdate: 'pointer', pointerType: null }, + interaction: { + item: null, + pointer: null, + lastUpdate: 'pointer', + pointerType: null, + hoveredItem: null, + }, }); useChartInteraction.params = {}; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.types.ts index 2ed0ca78b6248..4b40c557810a0 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartInteraction/useChartInteraction.types.ts @@ -1,4 +1,6 @@ import type { ChartPluginSignature } from '../../models'; +import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; +import type { ChartSeriesType } from '../../../../models/seriesType/config'; export type Coordinate = { x: number; y: number }; @@ -21,6 +23,17 @@ export interface UseChartInteractionInstance { * @returns {void} */ setLastUpdateSource: (interaction: InteractionUpdateSource) => void; + /** + * Set the item the pointer is over. + * @param {SeriesItemIdentifierWithType} item The item under the pointer. + */ + setHoveredItem: (item: SeriesItemIdentifierWithType) => void; + /** + * Remove the item the pointer was over. + * @param {SeriesItemIdentifierWithType} itemToRemove Only clears the item when it is the current + * one. Omit to clear unconditionally. + */ + clearHoveredItem: (itemToRemove?: SeriesItemIdentifierWithType) => void; /** * Handle pointer enter event on the chart Surface. */ @@ -47,6 +60,13 @@ export interface UseChartInteractionState { * Used to decide if highlight should be based on pointer position or keyboard navigation. */ lastUpdate: InteractionUpdateSource; + /** + * The item the pointer is currently over. + * + * Unlike the highlighted and the tooltip items, this one is never controlled, so it always + * reflects the pointer. That is what makes it usable to resolve what a click landed on. + */ + hoveredItem: SeriesItemIdentifierWithType | null; }; } diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts index 2f59c0c438ef4..d1b377c021685 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.ts @@ -2,10 +2,7 @@ import type { ChartPlugin } from '../../models'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; import type { UseChartItemClickSignature } from './useChartItemClick.types'; -import type { - FocusedItemIdentifier, - SeriesItemIdentifierWithType, -} from '../../../../models/seriesType'; +import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; import { getChartPoint } from '../../../getChartPoint'; export const useChartItemClick: ChartPlugin> = ({ @@ -15,6 +12,10 @@ export const useChartItemClick: ChartPlugin> = ( }) => { const { onItemClick } = params; + if (!onItemClick) { + return { instance: {} }; + } + const getItemPosition = (event: React.MouseEvent) => { const svgPoint = getChartPoint(event?.currentTarget, event); @@ -43,8 +44,7 @@ export const useChartItemClick: ChartPlugin> = ( handleClick: (event: React.MouseEvent) => { const item = getItemPosition(event); if (item !== undefined) { - instance.focusItem?.(item as FocusedItemIdentifier); - onItemClick?.(event, item); + onItemClick(event, item); } }, }, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts index 884527f28f33e..7f0627ffb267a 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartItemClick/useChartItemClick.types.ts @@ -2,7 +2,6 @@ import type { ChartPluginSignature } from '../../models'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; import type { SeriesItemIdentifierWithType } from '../../../../models/seriesType'; import type { ChartSeriesTypeRequiredPlugins } from '../../corePlugins/useChartSeriesConfig'; -import type { UseChartKeyboardNavigationSignature } from '../useChartKeyboardNavigation'; export interface UseChartItemClickParameters { /** @@ -29,5 +28,4 @@ export type UseChartItemClickSignature; instance: UseChartItemClickInstance; dependencies: ChartSeriesTypeRequiredPlugins; - optionalDependencies: [UseChartKeyboardNavigationSignature]; }>; diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 7443170b5b150..569b6f2ab5d5c 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -46,12 +46,6 @@ export const useChartKeyboardNavigation: ChartPlugin { + const focusItemAtPointer = useEventCallback((event: MouseEvent | PointerEvent) => { + // Every series reports the item under the pointer, so the click itself needs no wiring. + const hoveredItem = store.state.interaction?.hoveredItem; + if (hoveredItem != null && focusItem(hoveredItem as FocusedItemIdentifier)) { + return; + } + const element = chartsLayerContainerRef.current; const point = element === null ? null : getChartPoint(element, event); @@ -216,7 +215,6 @@ export const useChartKeyboardNavigation: ChartPlugin { + focusItemAtPointer(event.detail.srcEvent as PointerEvent); + }); return () => { element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); element.removeEventListener('pointerdown', trackPointerIntent, true); - document.removeEventListener('click', focusChartOnClick); document.removeEventListener('keydown', trackKeyboardIntent, true); + tapHandler?.cleanup(); }; }, [ chartsLayerContainerRef, @@ -293,7 +282,8 @@ export const useChartKeyboardNavigation: ChartPlugin Date: Mon, 3 Aug 2026 18:32:52 +0200 Subject: [PATCH 21/27] wip-radar-split --- .../useChartKeyboardNavigation.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 569b6f2ab5d5c..99290a2e5c506 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -265,16 +265,12 @@ export const useChartKeyboardNavigation: ChartPlugin { - focusItemAtPointer(event.detail.srcEvent as PointerEvent); - }); return () => { element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); element.removeEventListener('pointerdown', trackPointerIntent, true); document.removeEventListener('keydown', trackKeyboardIntent, true); - tapHandler?.cleanup(); }; }, [ chartsLayerContainerRef, @@ -282,11 +278,21 @@ export const useChartKeyboardNavigation: ChartPlugin { + if (params.disableKeyboardNavigation) { + return undefined; + } + + const tapHandler = instance.addInteractionListener?.('tap', (event) => { + focusItemAtPointer(event.detail.srcEvent as PointerEvent); + }); + + return () => tapHandler?.cleanup(); + }, [instance, params.disableKeyboardNavigation, focusItemAtPointer]); + useEnhancedEffect(() => { store.set('keyboardNavigation', { ...store.state.keyboardNavigation, From f2c68fab395e53113a626ac2293765e49d12a4c0 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 3 Aug 2026 18:35:11 +0200 Subject: [PATCH 22/27] [charts] Register the tap listener in its own effect 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. --- .../RadarSeriesPlot/RadarSeriesArea.tsx | 17 ------ .../RadarSeriesPlot/RadarSeriesMarks.tsx | 11 ---- .../RadarSeriesPlot/RadarSeriesPlot.test.tsx | 32 ------------ .../RadarSeriesPlot/RadarSeriesPlot.tsx | 24 --------- ...rtKeyboardNavigation.clickToFocus.test.tsx | 52 ------------------- 5 files changed, 136 deletions(-) diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesArea.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesArea.tsx index d66a473524567..bf9cd41a83431 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesArea.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesArea.tsx @@ -10,8 +10,6 @@ import { useInteractionAllItemProps } from './useInteractionAllItemProps'; import type { SeriesId, HighlightItemIdentifierWithType } from '../../models/seriesType'; import type { HighlightState } from '../../hooks/useItemHighlightState'; import { useRadarRotationIndex } from './useRadarRotationIndex'; -import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; -import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; interface GetPathPropsParams { seriesId: SeriesId; @@ -47,21 +45,8 @@ function RadarSeriesArea(props: RadarSeriesAreaProps) { const getRotationIndex = useRadarRotationIndex(); const interactionProps = useInteractionAllItemProps(seriesCoordinates); - const { instance } = useChartsContext<[UseChartInteractionSignature]>(); const getHighlightState = useItemHighlightStateGetter<'radar'>(); - /** - * The radar area only knows its series, so the pointer item is resolved from the angle. Also - * bound to `pointerdown`, because a touch tap may never produce a `pointermove`. - */ - const handlePointerItem = (seriesId: SeriesId) => (event: React.PointerEvent) => { - instance.setHoveredItem?.({ - type: 'radar', - seriesId, - dataIndex: getRotationIndex(event), - }); - }; - const classes = useUtilityClasses(inClasses); return ( @@ -91,8 +76,6 @@ function RadarSeriesArea(props: RadarSeriesAreaProps) { } cursor={onItemClick ? 'pointer' : 'unset'} {...interactionProps[seriesIndex]} - onPointerMove={handlePointerItem(id)} - onPointerDown={handlePointerItem(id)} {...other} /> ); diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx index f416a92b2e7f0..a64ac26555e16 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesMarks.tsx @@ -8,8 +8,6 @@ import { useItemHighlightStateGetter } from '../../hooks/useItemHighlightStateGe import type { SeriesId } from '../../models/seriesType/common'; import type { HighlightItemIdentifierWithType } from '../../models'; import type { HighlightState } from '../../hooks/useItemHighlightState'; -import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; -import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; interface GetCirclePropsParams { seriesId: SeriesId; @@ -45,7 +43,6 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { const classes = useUtilityClasses(inClasses); const getHighlightState = useItemHighlightStateGetter(); - const { instance } = useChartsContext<[UseChartInteractionSignature]>(); return ( @@ -68,14 +65,6 @@ function RadarSeriesMarks(props: RadarSeriesMarksProps) { classes, })} pointerEvents={onItemClick ? undefined : 'none'} - // Marks only take pointer events when clickable, and then they hide the area - // underneath, so they have to report the pointer item themselves. - onPointerMove={() => - instance.setHoveredItem?.({ type: 'radar', seriesId: id, dataIndex: index }) - } - onPointerDown={() => - instance.setHoveredItem?.({ type: 'radar', seriesId: id, dataIndex: index }) - } onClick={(event) => onItemClick?.(event, { type: 'radar', seriesId: id, dataIndex: index }) } diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx index 9db8086478609..e40da8c30464c 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.test.tsx @@ -1,7 +1,4 @@ import { createRenderer } from '@mui/internal-test-utils/createRenderer'; -import { vi } from 'vitest'; -import { isJSDOM } from 'test/utils/skipIf'; -import { getCenter } from 'test/utils/charts/getCenter'; import { RadarChart, RadarSeriesPlot, radarClasses } from '@mui/x-charts/RadarChart'; describe('', () => { @@ -22,33 +19,4 @@ describe('', () => { const root = container.querySelector(`.${radarClasses.seriesRoot}.custom-radar-series`); expect(root).not.to.equal(null); }); - - // The interaction props now carry a click handler for the keyboard focus. It must be merged - // with the consumer callback, not replace it. - it.skipIf(isJSDOM)('should fire onAreaClick with the clicked rotation index', async () => { - const onAreaClick = vi.fn(); - const { container, user } = render( - - - , - ); - - const plot = container.querySelector('.custom-radar-series')!; - const area = plot.querySelector(`.${radarClasses.seriesArea}`)!; - const mark = plot.querySelectorAll('circle')[2]; - - await user.pointer([{ keys: '[MouseLeft]', target: area, coords: getCenter(mark) }]); - - expect(onAreaClick.mock.calls.length).to.equal(1); - expect(onAreaClick.mock.lastCall?.[1]).to.deep.equal({ - type: 'radar', - seriesId: 'radar', - dataIndex: 2, - }); - }); }); diff --git a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx index 3772e87b171df..3dcd44278a7f7 100644 --- a/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx +++ b/packages/x-charts/src/RadarChart/RadarSeriesPlot/RadarSeriesPlot.tsx @@ -8,9 +8,6 @@ import { useItemHighlightStateGetter } from '../../hooks/useItemHighlightStateGe import { getPathProps } from './RadarSeriesArea'; import { getCircleProps } from './RadarSeriesMarks'; import { useRadarRotationIndex } from './useRadarRotationIndex'; -import { useChartsContext } from '../../context/ChartsProvider/useChartsContext'; -import type { UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction'; -import type { SeriesId } from '../../models/seriesType'; function RadarSeriesPlot(props: RadarSeriesPlotProps) { const { seriesId: inSeriesId, className, classes: inClasses, onAreaClick, onMarkClick } = props; @@ -18,21 +15,8 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { const getRotationIndex = useRadarRotationIndex(); const interactionProps = useInteractionAllItemProps(seriesCoordinates); - const { instance } = useChartsContext<[UseChartInteractionSignature]>(); const getHighlightState = useItemHighlightStateGetter(); - /** - * The radar area only knows its series, so the pointer item is resolved from the angle. Also - * bound to `pointerdown`, because a touch tap may never produce a `pointermove`. - */ - const handlePointerItem = (seriesId: SeriesId) => (event: React.PointerEvent) => { - instance.setHoveredItem?.({ - type: 'radar', - seriesId, - dataIndex: getRotationIndex(event), - }); - }; - const classes = useUtilityClasses(inClasses); return ( @@ -65,8 +49,6 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { } cursor={onAreaClick ? 'pointer' : 'unset'} {...interactionProps[seriesIndex]} - onPointerMove={handlePointerItem(seriesId)} - onPointerDown={handlePointerItem(seriesId)} /> } {!hideMark && @@ -85,12 +67,6 @@ function RadarSeriesPlot(props: RadarSeriesPlotProps) { onMarkClick?.(event, { type: 'radar', seriesId, dataIndex: index }) } cursor={onMarkClick ? 'pointer' : 'unset'} - onPointerMove={() => - instance.setHoveredItem?.({ type: 'radar', seriesId, dataIndex: index }) - } - onPointerDown={() => - instance.setHoveredItem?.({ type: 'radar', seriesId, dataIndex: index }) - } /> ))} diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 4ede29b4d50a6..47365edec6219 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -9,7 +9,6 @@ import { chartsSvgLayerClasses } from '@mui/x-charts/ChartsSvgLayer'; import { PieChart, pieClasses } from '@mui/x-charts/PieChart'; import { LineChart, lineClasses } from '@mui/x-charts/LineChart'; import { ScatterChart } from '@mui/x-charts/ScatterChart'; -import { RadarChart, radarClasses } from '@mui/x-charts/RadarChart'; describe('useChartKeyboardNavigation - click to focus', () => { const { render } = createRenderer(); @@ -458,55 +457,4 @@ describe('useChartKeyboardNavigation - click to focus', () => { }); }); }); - - describe.skipIf(isJSDOM)('radar chart', () => { - const radarProps = { - height: 300, - width: 300, - radar: { metrics: ['A', 'B', 'C', 'D'] }, - series: [{ id: 'radar', data: [10, 20, 30, 40] }], - }; - - /** The focus indicator is a rect centered on the focused point. */ - function getFocusedMarkIndex(container: HTMLElement) { - const indicator = getFocusIndicator(container); - if (!indicator) { - return null; - } - - const center = getCenter(indicator); - return Array.from(container.querySelectorAll('circle')).findIndex((mark) => { - const markCenter = getCenter(mark); - return ( - Math.abs(markCenter.clientX - center.clientX) < 2 && - Math.abs(markCenter.clientY - center.clientY) < 2 - ); - }); - } - - it('focuses the clicked mark', async () => { - const { container, user } = render( {}} />); - - await user.click(container.querySelectorAll('circle')[2]); - - // Hidden, but stored: the next key moves on from the clicked mark. - expect(getFocusIndicator(container)).to.equal(null); - await user.keyboard('[ArrowRight]'); - - expect(getFocusedMarkIndex(container)).to.equal(3); - }); - - it('focuses the mark through the area when no click callback is set', async () => { - // Marks are pointer transparent without `onMarkClick`, so the click lands on the area, - // which resolves the index from the click angle. - const { container, user } = render(); - - const marks = container.querySelectorAll('circle'); - const area = container.querySelector(`.${radarClasses.seriesArea}`)!; - await user.pointer([{ keys: '[MouseLeft]', target: area, coords: getCenter(marks[2]) }]); - await user.keyboard('[ArrowRight]'); - - expect(getFocusedMarkIndex(container)).to.equal(3); - }); - }); }); From b84cfe492e658a241587fc0550164047f6667f77 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 3 Aug 2026 19:03:09 +0200 Subject: [PATCH 23/27] [charts-premium] Cover click to focus on the radial charts 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. --- .../RadialBarChart.clickToFocus.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/x-charts-premium/src/RadialBarChart/RadialBarChart.clickToFocus.test.tsx diff --git a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.clickToFocus.test.tsx b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.clickToFocus.test.tsx new file mode 100644 index 0000000000000..82a571aa9df94 --- /dev/null +++ b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.clickToFocus.test.tsx @@ -0,0 +1,27 @@ +import { createRenderer } from '@mui/internal-test-utils/createRenderer'; +import { isJSDOM } from 'test/utils/skipIf'; +import { getCenter } from 'test/utils/charts/getCenter'; +import { chartsSvgLayerClasses } from '@mui/x-charts/ChartsSvgLayer'; +import { RadialBarChart, radialBarClasses } from '@mui/x-charts-premium/RadialBarChart'; + +// Radial bars are resolved by a container hit test, so the clicks need real coordinates. +describe.skipIf(isJSDOM)(' - click to focus', () => { + const { render } = createRenderer(); + + it('makes the clicked bar the item keyboard navigation resumes from', async () => { + const { container, user } = render( + , + ); + + const bar = container.querySelectorAll(`.${radialBarClasses.element}`)[1]; + const layerContainer = container.querySelector( + `.${chartsSvgLayerClasses.root}`, + )!.parentElement!; + + await user.pointer([{ keys: '[MouseLeft]', target: layerContainer, coords: getCenter(bar) }]); + + // Hidden until a key is pressed, but the chart took the focus. + expect(container.querySelector('[fill="none"][stroke-width="2"]')).to.equal(null); + expect(container.contains(document.activeElement)).to.equal(true); + }); +}); From 70de9c0b934ffd67512c92d4a483331e1db930ad Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 3 Aug 2026 19:25:58 +0200 Subject: [PATCH 24/27] [charts] Keep the tooltip on the pointer when clicking an item 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. --- ...rtKeyboardNavigation.clickToFocus.test.tsx | 50 +++++++++++++++++++ .../useChartKeyboardNavigation.ts | 18 +++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 47365edec6219..fa242292816d7 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { isJSDOM } from 'test/utils/skipIf'; import { createRenderer } from '@mui/internal-test-utils/createRenderer'; +import { useChartsContext } from '@mui/x-charts/internals'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import { vi } from 'vitest'; import { getCenter } from 'test/utils/charts/getCenter'; @@ -13,6 +14,21 @@ import { ScatterChart } from '@mui/x-charts/ScatterChart'; describe('useChartKeyboardNavigation - click to focus', () => { const { render } = createRenderer(); + const storeRef: { current: { state: { interaction: { lastUpdate: string } } } | null } = { + current: null, + }; + + /** Reads the state that decides whether the tooltip follows the pointer or the focused item. */ + function StoreProbe() { + const { store } = useChartsContext(); + React.useEffect(() => { + storeRef.current = store as any; + }, [store]); + return null; + } + + const getLastUpdate = () => storeRef.current?.state.interaction.lastUpdate; + const FOCUS_INDICATOR_SELECTOR = '[fill="none"][stroke-width="2"]'; const barProps = { @@ -151,6 +167,40 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(bars[0].getAttribute('data-highlighted')).to.equal(null); }); + // A click moves the focus, but the pointer is still on the chart, so the highlight and the + // tooltip have to keep following it. They switch to the focused item only for the keyboard, + // which is what `interaction.lastUpdate` selects. + it('leaves the tooltip on the pointer when clicking, whatever the focus mode', async () => { + const { container, user } = render( + + + , + ); + + await clickAt(user, container, getCenter(getBars(container)[1])); + expect(getLastUpdate()).to.equal('pointer'); + + // Same once the focus is visible: the click must not hand the tooltip over. + await user.keyboard('[ArrowRight]'); + expect(getLastUpdate()).to.equal('keyboard'); + + await clickAt(user, container, getCenter(getBars(container)[2])); + expect(getLastUpdate()).to.equal('pointer'); + }); + + it('leaves the tooltip on the pointer with focusItemOnClick', async () => { + const { container, user } = render( + + + , + ); + + await clickAt(user, container, getCenter(getBars(container)[1])); + + expect(getFocusedDataIndex(container)).to.equal(1); + expect(getLastUpdate()).to.equal('pointer'); + }); + it('falls back to the axis when the click hits no item', async () => { const { container, user } = render(); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index 99290a2e5c506..f7179a5d68ff8 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -46,6 +46,13 @@ export const useChartKeyboardNavigation: ChartPlugin | null | undefined, isFocusVisible: boolean) => { const keyboardNavigation = store.state.keyboardNavigation; const isFocused = chartsLayerContainerRef.current?.contains(document.activeElement) ?? false; + // Only the keyboard hands the highlight and the tooltip over to the focused item. A click + // moves the focus, but the pointer is still there and they must keep following it. + const followsFocus = isFocused && isFocusVisible && isKeyboardModalityRef.current; store.update({ - ...(isFocused && - isFocusVisible && + ...(followsFocus && store.state.highlight && { highlight: { ...store.state.highlight, lastUpdate: 'keyboard' }, }), - ...(isFocused && - isFocusVisible && + ...(followsFocus && store.state.interaction && { interaction: { ...store.state.interaction, lastUpdate: 'keyboard' }, }), @@ -215,12 +223,14 @@ export const useChartKeyboardNavigation: ChartPlugin Date: Mon, 3 Aug 2026 20:02:56 +0200 Subject: [PATCH 25/27] [charts] Focus the tapped item on touch 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. --- ...rtKeyboardNavigation.clickToFocus.test.tsx | 14 ++++++ .../useChartKeyboardNavigation.ts | 49 ++++++++++++++----- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index fa242292816d7..6db72d75dc8f5 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -201,6 +201,20 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(getLastUpdate()).to.equal('pointer'); }); + // Touch has no hover phase, and a stationary tap is not guaranteed to produce a + // `pointermove`. The item is resolved on `pointerdown` so the tap still lands on it. + it('focuses the tapped item on touch', async () => { + const { container, user } = render(); + const bar = getBars(container)[2]; + + await user.pointer([ + { keys: '[TouchA]', target: getLayerContainer(container), coords: getCenter(bar) }, + ]); + await user.keyboard('[ArrowRight]'); + + expect(getFocusedDataIndex(container)).to.equal(3); + }); + it('falls back to the axis when the click hits no item', async () => { const { container, user } = render(); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index f7179a5d68ff8..7d9f30ceed5ad 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -53,6 +53,16 @@ export const useChartKeyboardNavigation: ChartPlugin | null>(null); + /** * Writes the focus state, only switching the highlight and tooltip to keyboard when visible. * An `undefined` item leaves the focused item untouched, `null` clears it. @@ -150,19 +160,19 @@ export const useChartKeyboardNavigation: ChartPlugin { + const resolveItemAtPointer = useEventCallback((event: MouseEvent | PointerEvent) => { // Every series reports the item under the pointer, so the click itself needs no wiring. const hoveredItem = store.state.interaction?.hoveredItem; - if (hoveredItem != null && focusItem(hoveredItem as FocusedItemIdentifier)) { - return; + if (hoveredItem != null) { + return hoveredItem as FocusedItemIdentifier; } const element = chartsLayerContainerRef.current; @@ -179,9 +189,7 @@ export const useChartKeyboardNavigation: ChartPlugin { @@ -224,6 +232,7 @@ export const useChartKeyboardNavigation: ChartPlugin { - focusItemAtPointer(event.detail.srcEvent as PointerEvent); + pendingFocusItemRef.current = resolveItemAtPointer(event.detail.srcEvent as PointerEvent); }); - return () => tapHandler?.cleanup(); - }, [instance, params.disableKeyboardNavigation, focusItemAtPointer]); + function focusPendingItem() { + const item = pendingFocusItemRef.current; + pendingFocusItemRef.current = null; + + if (item !== null) { + focusItem(item); + } + } + + // Listens on the document so it runs after the React handlers, which are attached to the + // React root above the chart container. + document.addEventListener('click', focusPendingItem); + + return () => { + tapHandler?.cleanup(); + document.removeEventListener('click', focusPendingItem); + }; + }, [instance, params.disableKeyboardNavigation, resolveItemAtPointer, focusItem]); useEnhancedEffect(() => { store.set('keyboardNavigation', { From 6077861a90316a57c452601046fee16166a7f5ca Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 6 Aug 2026 18:16:21 +0200 Subject: [PATCH 26/27] [charts] Drop a pending item when the click is stopped 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. --- ...rtKeyboardNavigation.clickToFocus.test.tsx | 22 +++++++++++++++++++ .../useChartKeyboardNavigation.ts | 14 +++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx index 6db72d75dc8f5..93c347702d9e7 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -215,6 +215,28 @@ describe('useChartKeyboardNavigation - click to focus', () => { expect(getFocusedDataIndex(container)).to.equal(3); }); + // A handler stopping the propagation keeps the click from reaching the document listener, so + // the resolved item stays pending. It must not land on whatever the user clicks next. + it('drops a pending item when the click never reaches the document', async () => { + const { container, user } = render( +
+ + +
, + ); + + getLayerContainer(container).addEventListener('click', (event) => event.stopPropagation()); + + await clickAt(user, container, getCenter(getBars(container)[1])); + await user.click(container.querySelector('#outside')!); + await user.keyboard('[ArrowRight]'); + + // The click outside must not have focused the bar the stopped click resolved. + expect(getFocusIndicator(container)).to.equal(null); + }); + it('falls back to the axis when the click hits no item', async () => { const { container, user } = render(); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts index fea35547d7303..76760cef42d69 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -367,11 +367,13 @@ export const useChartKeyboardNavigation: ChartPlugin { store.set('keyboardNavigation', { From 925e59961baddf58b5e68102284ba104f6348cd5 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 7 Aug 2026 11:35:58 +0200 Subject: [PATCH 27/27] [docs] Regenerate the API documentation after the merge 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. --- docs/pages/x/api/charts/bar-chart-premium.json | 1 + docs/pages/x/api/charts/bar-chart-pro.json | 1 + docs/pages/x/api/charts/bar-chart.json | 1 + docs/pages/x/api/charts/charts-container.json | 1 + docs/pages/x/api/charts/funnel-chart.json | 1 + docs/pages/x/api/charts/heatmap-premium.json | 1 + docs/pages/x/api/charts/heatmap.json | 1 + docs/pages/x/api/charts/pie-chart-pro.json | 1 + docs/pages/x/api/charts/pie-chart.json | 1 + docs/pages/x/api/charts/radar-chart-pro.json | 1 + docs/pages/x/api/charts/radar-chart.json | 1 + docs/pages/x/api/charts/radial-bar-chart.json | 1 + docs/pages/x/api/charts/radial-line-chart.json | 1 + docs/pages/x/api/charts/sankey-chart.json | 1 + docs/pages/x/api/charts/sankey-data-provider.json | 1 + 15 files changed, 15 insertions(+) diff --git a/docs/pages/x/api/charts/bar-chart-premium.json b/docs/pages/x/api/charts/bar-chart-premium.json index 4658e66e7310b..febb060da72fa 100644 --- a/docs/pages/x/api/charts/bar-chart-premium.json +++ b/docs/pages/x/api/charts/bar-chart-premium.json @@ -33,6 +33,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/bar-chart-pro.json b/docs/pages/x/api/charts/bar-chart-pro.json index 872b3c947fa46..7650a9f1511bf 100644 --- a/docs/pages/x/api/charts/bar-chart-pro.json +++ b/docs/pages/x/api/charts/bar-chart-pro.json @@ -33,6 +33,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/bar-chart.json b/docs/pages/x/api/charts/bar-chart.json index b2aed3e37aadc..afbc9cbec75ec 100644 --- a/docs/pages/x/api/charts/bar-chart.json +++ b/docs/pages/x/api/charts/bar-chart.json @@ -33,6 +33,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ horizontal?: bool, vertical?: bool }" } }, diff --git a/docs/pages/x/api/charts/charts-container.json b/docs/pages/x/api/charts/charts-container.json index 0ed6ae9c9ba46..6b837a45db73d 100644 --- a/docs/pages/x/api/charts/charts-container.json +++ b/docs/pages/x/api/charts/charts-container.json @@ -19,6 +19,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/funnel-chart.json b/docs/pages/x/api/charts/funnel-chart.json index d320581b3a584..95a7469882662 100644 --- a/docs/pages/x/api/charts/funnel-chart.json +++ b/docs/pages/x/api/charts/funnel-chart.json @@ -31,6 +31,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "gap": { "type": { "name": "number" }, "default": "0" }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/heatmap-premium.json b/docs/pages/x/api/charts/heatmap-premium.json index 7572531efdeff..2059d3d3d812f 100644 --- a/docs/pages/x/api/charts/heatmap-premium.json +++ b/docs/pages/x/api/charts/heatmap-premium.json @@ -30,6 +30,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hideLegend": { "type": { "name": "bool" } }, "highlightedItem": { diff --git a/docs/pages/x/api/charts/heatmap.json b/docs/pages/x/api/charts/heatmap.json index f6a320d22024d..59ff3c6b58ac5 100644 --- a/docs/pages/x/api/charts/heatmap.json +++ b/docs/pages/x/api/charts/heatmap.json @@ -30,6 +30,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hideLegend": { "type": { "name": "bool" } }, "highlightedItem": { diff --git a/docs/pages/x/api/charts/pie-chart-pro.json b/docs/pages/x/api/charts/pie-chart-pro.json index 1b97f9f66a282..c40eb8c8f8d8f 100644 --- a/docs/pages/x/api/charts/pie-chart-pro.json +++ b/docs/pages/x/api/charts/pie-chart-pro.json @@ -14,6 +14,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/pie-chart.json b/docs/pages/x/api/charts/pie-chart.json index 5cea7967047c8..c476355e83185 100644 --- a/docs/pages/x/api/charts/pie-chart.json +++ b/docs/pages/x/api/charts/pie-chart.json @@ -14,6 +14,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radar-chart-pro.json b/docs/pages/x/api/charts/radar-chart-pro.json index 9e179d013adbf..8dd0736ac1c2d 100644 --- a/docs/pages/x/api/charts/radar-chart-pro.json +++ b/docs/pages/x/api/charts/radar-chart-pro.json @@ -23,6 +23,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radar-chart.json b/docs/pages/x/api/charts/radar-chart.json index 6e00c0c05bd85..f2f6c4584f4f0 100644 --- a/docs/pages/x/api/charts/radar-chart.json +++ b/docs/pages/x/api/charts/radar-chart.json @@ -23,6 +23,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "hiddenItems": { "type": { diff --git a/docs/pages/x/api/charts/radial-bar-chart.json b/docs/pages/x/api/charts/radial-bar-chart.json index a421ef597118b..8669ce151716e 100644 --- a/docs/pages/x/api/charts/radial-bar-chart.json +++ b/docs/pages/x/api/charts/radial-bar-chart.json @@ -25,6 +25,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ radius?: bool, rotation?: bool }" } }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/radial-line-chart.json b/docs/pages/x/api/charts/radial-line-chart.json index b2c5a0361fce5..461ae6623e31d 100644 --- a/docs/pages/x/api/charts/radial-line-chart.json +++ b/docs/pages/x/api/charts/radial-line-chart.json @@ -26,6 +26,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "grid": { "type": { "name": "shape", "description": "{ radius?: bool, rotation?: bool }" } }, "height": { "type": { "name": "number" } }, "hiddenItems": { diff --git a/docs/pages/x/api/charts/sankey-chart.json b/docs/pages/x/api/charts/sankey-chart.json index ba1e40b2c15f5..8c72f9fb67b4b 100644 --- a/docs/pages/x/api/charts/sankey-chart.json +++ b/docs/pages/x/api/charts/sankey-chart.json @@ -11,6 +11,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "highlightedItem": { "type": { diff --git a/docs/pages/x/api/charts/sankey-data-provider.json b/docs/pages/x/api/charts/sankey-data-provider.json index 9f2298f763119..5f864d2ec58a3 100644 --- a/docs/pages/x/api/charts/sankey-data-provider.json +++ b/docs/pages/x/api/charts/sankey-data-provider.json @@ -9,6 +9,7 @@ "experimentalFeatures": { "type": { "name": "shape", "description": "{ keyboardActivation?: bool }" } }, + "focusItemOnClick": { "type": { "name": "bool" }, "default": "false" }, "height": { "type": { "name": "number" } }, "highlightedItem": { "type": {