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 2fd385734d91a..87d1cc05c8832 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 }, @@ -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,38 @@ 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. + +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 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: + +- 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: { + MuiChartsDataProvider: { + defaultProps: { + focusItemOnClick: true + }, + }, +} +``` + ### Activating the focused item Charts can trigger their click callbacks from the keyboard, so mouse-only interactions such as drill-down or filtering stay available to keyboard users ([WCAG 2.1 SC 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)). @@ -106,6 +138,9 @@ Both become the default in v10. When a chart exposes several item callbacks, activation fires the one a pointer would reach first on the focused data point: line charts try `onMarkClick`, then `onLineClick`, then `onAreaClick`; radar charts try `onMarkClick`, then `onAreaClick`; and Sankey charts call `onNodeClick` or `onLinkClick` depending on the focused element. Only one of them fires. +Activation acts on the visible focus indicator. +A click sets the item keyboard navigation resumes from without revealing it, so Enter and Space do nothing until a key press makes the focus visible. + ## Screen reader compatibility Charts use a proxy strategy to support screen reader when user navigate with keyboard navigation. @@ -132,3 +167,36 @@ 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 + +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, + seriesId, + dataIndex, + isHighlighted, + isFaded, + ...other +}) { + // `other` carries the pointer handlers that report the item. + return ( + + ); +} +``` + +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 }) { + return ; +} +``` 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/line-chart-pro.json b/docs/pages/x/api/charts/line-chart-pro.json index ae75ec8e57213..eacf777a96914 100644 --- a/docs/pages/x/api/charts/line-chart-pro.json +++ b/docs/pages/x/api/charts/line-chart-pro.json @@ -37,6 +37,7 @@ "description": "{ enablePositionBasedPointerInteraction?: bool, 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/line-chart.json b/docs/pages/x/api/charts/line-chart.json index 6523d17517911..78c360a3fabac 100644 --- a/docs/pages/x/api/charts/line-chart.json +++ b/docs/pages/x/api/charts/line-chart.json @@ -37,6 +37,7 @@ "description": "{ enablePositionBasedPointerInteraction?: bool, 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/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": { diff --git a/docs/pages/x/api/charts/scatter-chart-premium.json b/docs/pages/x/api/charts/scatter-chart-premium.json index 392e5050af9f1..ca4a27f1e194a 100644 --- a/docs/pages/x/api/charts/scatter-chart-premium.json +++ b/docs/pages/x/api/charts/scatter-chart-premium.json @@ -49,6 +49,7 @@ "description": "{ keyboardActivation?: bool, 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 981805e9562f4..36e8286f06086 100644 --- a/docs/pages/x/api/charts/scatter-chart-pro.json +++ b/docs/pages/x/api/charts/scatter-chart-pro.json @@ -49,6 +49,7 @@ "description": "{ keyboardActivation?: bool, 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 ce9bb0d8a12d4..d71d93f4e30b6 100644 --- a/docs/pages/x/api/charts/scatter-chart.json +++ b/docs/pages/x/api/charts/scatter-chart.json @@ -49,6 +49,7 @@ "description": "{ keyboardActivation?: bool, 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 360193cd38351..57383ce4ad4af 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, keyboardActivation?: 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 29dee959fee7a..e952bf65e2d4d 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 890ff52ec3eff..768a22e124778 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 289392c5dae0b..e80e944836b18 100644 --- a/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx +++ b/packages/x-charts-premium/src/BarChartPremium/BarChartPremium.tsx @@ -248,6 +248,13 @@ BarChartPremium.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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-premium/src/HeatmapPremium/HeatmapPremium.tsx b/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx index 79cac5320a5fa..a6d7a7f057314 100644 --- a/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx +++ b/packages/x-charts-premium/src/HeatmapPremium/HeatmapPremium.tsx @@ -142,6 +142,13 @@ HeatmapPremium.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ 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); + }); +}); diff --git a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx index 9cb382669de62..e1f84a9cd170c 100644 --- a/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx +++ b/packages/x-charts-premium/src/RadialBarChart/RadialBarChart.tsx @@ -206,6 +206,13 @@ RadialBarChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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 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 63a0356aa9a37..6c0345d69e43d 100644 --- a/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx +++ b/packages/x-charts-premium/src/RadialLineChart/RadialLineChart.tsx @@ -223,6 +223,13 @@ RadialLineChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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 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 022b908d91cd1..6d93b88c81ead 100644 --- a/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx +++ b/packages/x-charts-premium/src/ScatterChartPremium/ScatterChartPremium.tsx @@ -253,6 +253,13 @@ ScatterChartPremium.propTypes /* remove-proptypes */ = { keyboardActivation: 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, /** * 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 2f5acd1419e3d..341b2a0aa00ad 100644 --- a/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx +++ b/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx @@ -209,6 +209,13 @@ BarChartPro.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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/FunnelChart/FunnelChart.tsx b/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx index 6c3ec05de70dd..f9659fb8502b5 100644 --- a/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx +++ b/packages/x-charts-pro/src/FunnelChart/FunnelChart.tsx @@ -262,6 +262,13 @@ FunnelChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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 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 57da17792e6f0..065700e3f3428 100644 --- a/packages/x-charts-pro/src/Heatmap/Heatmap.tsx +++ b/packages/x-charts-pro/src/Heatmap/Heatmap.tsx @@ -236,6 +236,13 @@ Heatmap.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ 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 ef495716c49ef..fbbf48e844d07 100644 --- a/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx +++ b/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx @@ -224,6 +224,13 @@ LineChartPro.propTypes /* remove-proptypes */ = { enablePositionBasedPointerInteraction: PropTypes.bool, keyboardActivation: 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 74b68b0903742..81af57231cd99 100644 --- a/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx +++ b/packages/x-charts-pro/src/PieChartPro/PieChartPro.tsx @@ -160,6 +160,13 @@ PieChartPro.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx b/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx index 7e84087038e80..e6a0f4b375497 100644 --- a/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx +++ b/packages/x-charts-pro/src/RadarChartPro/RadarChartPro.tsx @@ -158,6 +158,13 @@ RadarChartPro.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx index 74617a8f65341..7b4f78ec6fe43 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyChart.tsx @@ -119,6 +119,13 @@ SankeyChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx b/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx index b1d71687a0a53..945c7ed7fd909 100644 --- a/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx +++ b/packages/x-charts-pro/src/SankeyChart/SankeyDataProvider.tsx @@ -70,6 +70,13 @@ SankeyDataProvider.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx index f32d4cedd7286..a4e330d39e5ff 100644 --- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx @@ -228,6 +228,13 @@ ScatterChartPro.propTypes /* remove-proptypes */ = { keyboardActivation: 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, /** * 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 2a3900f01167f..997c3e284789a 100644 --- a/packages/x-charts/src/BarChart/BarChart.tsx +++ b/packages/x-charts/src/BarChart/BarChart.tsx @@ -231,6 +231,13 @@ BarChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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/ChartsContainer/ChartsContainer.tsx b/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx index e2ce67bceedca..207ba925372ba 100644 --- a/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx +++ b/packages/x-charts/src/ChartsContainer/ChartsContainer.tsx @@ -126,6 +126,13 @@ ChartsContainer.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ 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 70b3b82acea70..c9811146e71c9 100644 --- a/packages/x-charts/src/LineChart/LineChart.tsx +++ b/packages/x-charts/src/LineChart/LineChart.tsx @@ -263,6 +263,13 @@ LineChart.propTypes /* remove-proptypes */ = { enablePositionBasedPointerInteraction: PropTypes.bool, keyboardActivation: 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 0d695d7ff93d1..c1b1f52a94448 100644 --- a/packages/x-charts/src/PieChart/PieChart.tsx +++ b/packages/x-charts/src/PieChart/PieChart.tsx @@ -207,6 +207,13 @@ PieChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts/src/RadarChart/RadarChart.tsx b/packages/x-charts/src/RadarChart/RadarChart.tsx index 91062b7b58e97..17f3fc2c17619 100644 --- a/packages/x-charts/src/RadarChart/RadarChart.tsx +++ b/packages/x-charts/src/RadarChart/RadarChart.tsx @@ -171,6 +171,13 @@ RadarChart.propTypes /* remove-proptypes */ = { experimentalFeatures: PropTypes.shape({ keyboardActivation: 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. */ diff --git a/packages/x-charts/src/RadarChart/useRadarChartProps.ts b/packages/x-charts/src/RadarChart/useRadarChartProps.ts index 7370a7a179158..7df862717c984 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 30b390ad7c253..0891d658a07fd 100644 --- a/packages/x-charts/src/ScatterChart/ScatterChart.tsx +++ b/packages/x-charts/src/ScatterChart/ScatterChart.tsx @@ -271,6 +271,13 @@ ScatterChart.propTypes /* remove-proptypes */ = { keyboardActivation: 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, /** * 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 b9c8e08218626..18a02d38d64f4 100644 --- a/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx +++ b/packages/x-charts/src/SparkLineChart/SparkLineChart.tsx @@ -434,6 +434,13 @@ SparkLineChart.propTypes /* remove-proptypes */ = { keyboardActivation: 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. */ diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 865c48aae27ff..22b6409bf8540 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -37,12 +37,14 @@ export const useInteractionItemProps = ( 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(); }); @@ -81,6 +83,7 @@ export function getInteractionItemProps( return; } instance.setLastUpdateSource('pointer'); + instance.setHoveredItem(item); instance.setTooltipItem(item); instance.setHighlight(item); } @@ -89,6 +92,7 @@ export function getInteractionItemProps( if (!item) { return; } + instance.clearHoveredItem(item); instance.removeTooltipItem(item); instance.clearHighlight(); } diff --git a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx index 862066b71682b..de369332d66ec 100644 --- a/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx +++ b/packages/x-charts/src/internals/components/ChartsAccessibilityProxy/ChartsAccessibilityProxy.tsx @@ -1,6 +1,8 @@ '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'; /** @@ -30,12 +32,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; } @@ -110,8 +115,10 @@ export function ChartsAccessibilityProxy() { return (
); 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/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/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 0859a3ccfc41a..5e8e7c9612e9f 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartClosestPoint/useChartClosestPoint.ts @@ -176,6 +176,7 @@ export const useChartClosestPoint: ChartPlugin = const moveEndHandler = instance.addInteractionListener('moveEnd', (event) => { if (!event.detail.activeGestures.pan) { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); } @@ -183,6 +184,7 @@ export const useChartClosestPoint: ChartPlugin = const panEndHandler = instance.addInteractionListener('panEnd', (event) => { if (!event.detail.activeGestures.move) { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); } @@ -190,6 +192,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?.(); } @@ -200,6 +203,7 @@ export const useChartClosestPoint: ChartPlugin = if (closestPoint === 'outside-chart') { instance.cleanInteraction?.(); + instance.clearHoveredItem?.(); instance.clearHighlight?.(); instance.removeTooltipItem?.(); return; @@ -207,12 +211,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?.({ @@ -222,6 +228,13 @@ 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); @@ -236,6 +249,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/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/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx new file mode 100644 index 0000000000000..93c347702d9e7 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.clickToFocus.test.tsx @@ -0,0 +1,546 @@ +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'; +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'; + +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 = { + 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('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; + + 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); + }); + + // 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'); + }); + + // 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); + }); + + // 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(); + + // 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]'); + + // 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(); + + 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 axis fallback moved the item to the clicked column, still hidden. + await user.keyboard('[ArrowRight]'); + expect(getFocusedDataIndex(container)).to.equal(1); + }); + + 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); + }); + + // 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(); + + 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); + }); + }); + + // 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, + 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); + }); + + // 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(); + + 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, + }); + }); + }); +}); 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..42983732c0cc9 --- /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 any; + +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 0fb654849631c..3f86d3d264c96 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 @@ -18,10 +18,24 @@ 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), ); @@ -29,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, ); /** @@ -113,7 +127,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 7e8fdc491eeb2..76760cef42d69 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/useChartKeyboardNavigation.ts @@ -1,26 +1,204 @@ 'use client'; 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/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, ItemActivationHandler, ItemActivationScope, UseChartKeyboardNavigationSignature, } from './useChartKeyboardNavigation.types'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; +import type { FocusedItemIdentifier } from '../../../../models/seriesType'; import type { FocusedItemUpdater } from './keyboardFocusHandler.types'; import { findItemActivationHandler, isItemActivationKey } from './itemActivation'; import type { ItemActivationRegistration } from './itemActivation'; -import { selectorChartsIsKeyboardActivationEnabled } from './useChartKeyboardNavigation.selectors'; +import { + selectorChartsIsFocusVisible, + selectorChartsIsKeyboardActivationEnabled, +} from './useChartKeyboardNavigation.selectors'; + +/** + * 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); + + /** + * Whether the last interaction came from the keyboard. + * Only then do the highlight and the tooltip follow the focused item: after a click the pointer + * is still on the chart, and they must keep following it. + */ + const isKeyboardModalityRef = React.useRef(true); + + /** + * The item a tap resolved, waiting for the click to focus it. + * + * The two steps cannot be merged. Touch clears the pointer item on `pointerleave`, right after + * the tap, so the item has to be read early. But touch also emits its compatibility `mousedown` + * after that, which moves the focus to the body, so the focus has to be taken late, on the click + * that closes the sequence. + */ + const pendingFocusItemRef = React.useRef | 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. + * + * `isFocused` is read from the DOM rather than assumed: a composition may not render the + * accessibility proxy, and claiming a focus the chart does not hold would render an indicator + * that no blur can ever clear. The item is still stored, so it is restored on the next focus. + */ + const updateFocus = useEventCallback( + (item: FocusedItemIdentifier | 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({ + ...(followsFocus && + store.state.highlight && { + highlight: { ...store.state.highlight, lastUpdate: 'keyboard' }, + }), + ...(followsFocus && + store.state.interaction && { + interaction: { ...store.state.interaction, lastUpdate: 'keyboard' }, + }), + keyboardNavigation: { + ...keyboardNavigation, + ...(item !== undefined && { item }), + isFocused, + isFocusVisible: isFocused && isFocusVisible, + }, + }); + }, + ); + + /** + * 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 container = chartsLayerContainerRef.current; + if (!container?.contains(document.activeElement)) { + // Focusing fires `focusin` synchronously, which runs `restoreFocus` with the intent above. + focusAccessibilityProxy(chartsAccessibilityProxyRef.current); + } + + // Read after focusing, `restoreFocus` already ran. + const keyboardNavigation = store.state.keyboardNavigation; + const isSameItem = + item === undefined || + (keyboardNavigation.item != null && + fastObjectShallowCompare(keyboardNavigation.item, item)); + + 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 focusItem = useEventCallback( + (item: FocusedItemIdentifier, options?: FocusItemOptions) => { + 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; + } + + return applyFocus(cleanedItem, options); + }, + ); + + /** + * The item a click landed on, read from the item the pointer is over. Falls back to the axis + * under the pointer, which covers the line and area paths, whose hovered item only knows its + * series. + * + * A click that resolves nothing is left alone rather than focusing the chart. The drawing area + * is a rectangle, but the data rarely fills it: taking the focus from a click next to a pie, + * outside its circle, reads as the chart grabbing clicks that were not meant for it. + */ + 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) { + return hoveredItem as FocusedItemIdentifier; + } + + const element = chartsLayerContainerRef.current; + 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; + + return item; + }); const activationRegistrationsRef = React.useRef(new Map()); const nextRegistrationIdRef = React.useRef(0); @@ -62,25 +240,32 @@ export const useChartKeyboardNavigation: ChartPlugin { element.removeEventListener('keydown', activationHandler); element.removeEventListener('keydown', keyboardHandler); element.removeEventListener('focusout', removeFocus); element.removeEventListener('focusin', restoreFocus); + element.removeEventListener('pointerdown', trackPointerIntent, true); + document.removeEventListener('keydown', trackKeyboardIntent, true); + }; + }, [ + chartsLayerContainerRef, + params.disableKeyboardNavigation, + store, + updateFocus, + applyFocus, + params.focusItemOnClick, + ]); + + React.useEffect(() => { + if (params.disableKeyboardNavigation) { + return undefined; + } + + const tapHandler = instance.addInteractionListener?.('tap', (event) => { + pendingFocusItemRef.current = resolveItemAtPointer(event.detail.srcEvent as PointerEvent); + }); + + function focusPendingItem(event: MouseEvent) { + const item = pendingFocusItemRef.current; + pendingFocusItemRef.current = null; + + // A handler stopping the propagation keeps the click from reaching this listener, leaving + // the item pending. Checking the target stops it from landing on an unrelated later click. + if (item !== null && chartsLayerContainerRef.current?.contains(event.target as Node)) { + 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); }; - }, [chartsLayerContainerRef, params.disableKeyboardNavigation, store]); + }, [ + instance, + params.disableKeyboardNavigation, + resolveItemAtPointer, + focusItem, + chartsLayerContainerRef, + ]); useEnhancedEffect(() => { store.set('keyboardNavigation', { @@ -173,17 +401,19 @@ export const useChartKeyboardNavigation: ChartPlugin ({ keyboardNavigation: { item: null, isFocused: false, + isFocusVisible: false, enabled: !params.disableKeyboardNavigation, }, }); 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 fd364e922bc05..322aa32825cfb 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,6 +5,14 @@ import type { UseChartHighlightSignature } from '../useChartHighlight'; import type { FocusedItemIdentifier, SeriesId } from '../../../../models/seriesType'; import type { ChartSeriesType } from '../../../../models/seriesType/config'; +export interface FocusItemOptions { + /** + * Whether the focus indicator should be rendered. + * Defaults to `focusItemOnClick || `. + */ + visible?: boolean; +} + /** * Called when the focused item is activated with the keyboard. * @param {KeyboardEvent} event The keyboard event that triggered the activation. @@ -38,6 +46,16 @@ export interface ItemActivationScope { } 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; /** * Registers a handler triggered when the focused item is activated with the keyboard. * Only the handler with the most specific matching scope runs, so plots sharing a series @@ -62,6 +80,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. */ @@ -74,6 +97,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<{ 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..81acd33d918f7 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.test.ts @@ -0,0 +1,136 @@ +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('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); + }); + + 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..071c021dcc47c --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartKeyboardNavigation/utils/getItemAtAxisPosition.ts @@ -0,0 +1,105 @@ +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 { AxisId, 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 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]; + + 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; + } + + // The axis can be longer than the series it is shared with, and the index may be hidden. + const findVisible = (direction: 1 | -1) => + findVisibleDataIndex({ + processedSeries, + type: target.type, + seriesId: target.seriesId, + startIndex: Math.min(dataIndex, data.length - 1), + dataLength: data.length, + direction, + allowCycles: false, + }); + + const visibleDataIndex = findVisible(1) ?? findVisible(-1); + + 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; +}