From a5937296e01ac934b701945ec97302257061e1bf Mon Sep 17 00:00:00 2001
From: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com>
Date: Tue, 20 Aug 2024 09:36:19 +0200
Subject: [PATCH] [charts] Test pointer events (#14042)
---
.../src/BarChart/checkClickEvent.test.tsx | 155 ++++++++++
.../src/LineChart/checkClickEvent.test.tsx | 282 ++++++++++++++++++
.../src/PieChart/checkClickEvent.test.tsx | 74 +++++
.../src/ScatterChart/checkClickEvent.test.tsx | 183 ++++++++++++
packages/x-charts/src/internals/domUtils.ts | 18 +-
packages/x-charts/src/internals/index.ts | 1 +
.../x-charts/src/tests/firePointerEvent.ts | 41 +++
test/utils/mochaHooks.js | 2 +
8 files changed, 754 insertions(+), 2 deletions(-)
create mode 100644 packages/x-charts/src/BarChart/checkClickEvent.test.tsx
create mode 100644 packages/x-charts/src/LineChart/checkClickEvent.test.tsx
create mode 100644 packages/x-charts/src/PieChart/checkClickEvent.test.tsx
create mode 100644 packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx
create mode 100644 packages/x-charts/src/tests/firePointerEvent.ts
diff --git a/packages/x-charts/src/BarChart/checkClickEvent.test.tsx b/packages/x-charts/src/BarChart/checkClickEvent.test.tsx
new file mode 100644
index 000000000000..7804217e7402
--- /dev/null
+++ b/packages/x-charts/src/BarChart/checkClickEvent.test.tsx
@@ -0,0 +1,155 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { createRenderer, fireEvent } from '@mui/internal-test-utils';
+import { spy } from 'sinon';
+import { BarChart } from '@mui/x-charts/BarChart';
+import { firePointerEvent } from '../tests/firePointerEvent';
+
+const config = {
+ dataset: [
+ { x: 'A', v1: 4, v2: 2 },
+ { x: 'B', v1: 1, v2: 1 },
+ ],
+ margin: { top: 0, left: 0, bottom: 0, right: 0 },
+ width: 400,
+ height: 400,
+};
+
+// Plot as follow to simplify click position
+//
+// | X
+// | X
+// | X X
+// | X X X X
+// ---A---B-
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+describe('BarChart - click event', () => {
+ const { render } = createRenderer();
+
+ describe('onAxisClick', () => {
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onAxisClick = spy();
+ render(
+
+
+
,
+ );
+ const svg = document.querySelector('svg')!;
+
+ firePointerEvent(svg, 'pointermove', {
+ clientX: 198,
+ clientY: 60,
+ });
+ fireEvent.click(svg);
+
+ expect(onAxisClick.lastCall.args[1]).to.deep.equal({
+ dataIndex: 0,
+ axisValue: 'A',
+ seriesValues: { s1: 4, s2: 2 },
+ });
+
+ firePointerEvent(svg, 'pointermove', {
+ clientX: 201,
+ clientY: 60,
+ });
+ fireEvent.click(svg);
+
+ expect(onAxisClick.lastCall.args[1]).to.deep.equal({
+ dataIndex: 1,
+ axisValue: 'B',
+ seriesValues: { s1: 1, s2: 1 },
+ });
+ });
+ });
+
+ describe('onItemClick', () => {
+ it('should add cursor="pointer" to bar elements', function test() {
+ render(
+ {}}
+ />,
+ );
+ const rectangles = document.querySelectorAll('rect.MuiBarElement-root');
+
+ expect(
+ Array.from(rectangles).map((rectangle) => rectangle.getAttribute('cursor')),
+ ).to.deep.equal(['pointer', 'pointer', 'pointer', 'pointer']);
+ });
+
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onItemClick = spy();
+ render(
+
+
+
,
+ );
+
+ const rectangles = document.querySelectorAll('rect.MuiBarElement-root');
+
+ fireEvent.click(rectangles[0]);
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'bar',
+ seriesId: 's1',
+ dataIndex: 0,
+ });
+
+ fireEvent.click(rectangles[1]);
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'bar',
+ seriesId: 's1',
+ dataIndex: 1,
+ });
+
+ fireEvent.click(rectangles[2]);
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'bar',
+ seriesId: 's2',
+ dataIndex: 0,
+ });
+ });
+ });
+});
diff --git a/packages/x-charts/src/LineChart/checkClickEvent.test.tsx b/packages/x-charts/src/LineChart/checkClickEvent.test.tsx
new file mode 100644
index 000000000000..8c2e0800f227
--- /dev/null
+++ b/packages/x-charts/src/LineChart/checkClickEvent.test.tsx
@@ -0,0 +1,282 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { createRenderer, fireEvent } from '@mui/internal-test-utils';
+import { spy } from 'sinon';
+import { LineChart } from '@mui/x-charts/LineChart';
+import { firePointerEvent } from '../tests/firePointerEvent';
+
+const config = {
+ dataset: [
+ { x: 10, v1: 0, v2: 10 },
+ { x: 20, v1: 5, v2: 8 },
+ { x: 30, v1: 8, v2: 5 },
+ { x: 40, v1: 10, v2: 0 },
+ ],
+ margin: { top: 0, left: 0, bottom: 0, right: 0 },
+ width: 400,
+ height: 400,
+};
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+describe('LineChart - click event', () => {
+ const { render } = createRenderer();
+
+ describe('onAxisClick', () => {
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onAxisClick = spy();
+ render(
+
+
+
,
+ );
+ const svg = document.querySelector('svg')!;
+
+ firePointerEvent(svg, 'pointermove', {
+ clientX: 198,
+ clientY: 60,
+ });
+ fireEvent.click(svg);
+
+ expect(onAxisClick.lastCall.args[1]).to.deep.equal({
+ dataIndex: 1,
+ axisValue: 20,
+ seriesValues: { s1: 5, s2: 8 },
+ });
+
+ firePointerEvent(svg, 'pointermove', {
+ clientX: 201,
+ clientY: 60,
+ });
+ fireEvent.click(svg);
+
+ expect(onAxisClick.lastCall.args[1]).to.deep.equal({
+ dataIndex: 2,
+ axisValue: 30,
+ seriesValues: { s1: 8, s2: 5 },
+ });
+ });
+ });
+
+ describe('onMarkClick', () => {
+ it('should add cursor="pointer" to bar elements', function test() {
+ render(
+ {}}
+ />,
+ );
+ const marks = document.querySelectorAll('path.MuiMarkElement-root');
+
+ expect(Array.from(marks).map((mark) => mark.getAttribute('cursor'))).to.deep.equal([
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ 'pointer',
+ ]);
+ });
+
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onMarkClick = spy();
+ render(
+
+
+
,
+ );
+
+ const marks = document.querySelectorAll('path.MuiMarkElement-root');
+
+ fireEvent.click(marks[0]);
+ expect(onMarkClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's1',
+ dataIndex: 0,
+ });
+
+ fireEvent.click(marks[1]);
+ expect(onMarkClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's1',
+ dataIndex: 1,
+ });
+
+ fireEvent.click(marks[4]);
+ expect(onMarkClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's2',
+ dataIndex: 0,
+ });
+ });
+ });
+
+ describe('onAreaClick', () => {
+ it('should add cursor="pointer" to bar elements', function test() {
+ render(
+ {}}
+ />,
+ );
+ const areas = document.querySelectorAll('path.MuiAreaElement-root');
+
+ expect(Array.from(areas).map((area) => area.getAttribute('cursor'))).to.deep.equal([
+ 'pointer',
+ 'pointer',
+ ]);
+ });
+
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onAreaClick = spy();
+ render(
+
+
+
,
+ );
+
+ const areas = document.querySelectorAll('path.MuiAreaElement-root');
+
+ fireEvent.click(areas[0]);
+ expect(onAreaClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's1',
+ });
+
+ fireEvent.click(areas[1]);
+ expect(onAreaClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's2',
+ });
+ });
+ });
+
+ describe('onLineClick', () => {
+ it('should add cursor="pointer" to bar elements', function test() {
+ render(
+ {}}
+ />,
+ );
+ const lines = document.querySelectorAll('path.MuiLineElement-root');
+
+ expect(Array.from(lines).map((line) => line.getAttribute('cursor'))).to.deep.equal([
+ 'pointer',
+ 'pointer',
+ ]);
+ });
+
+ it('should provide the right context as second argument', function test() {
+ if (isJSDOM) {
+ // can't do Pointer event with JSDom https://github.com/jsdom/jsdom/issues/2527
+ this.skip();
+ }
+ const onLineClick = spy();
+ render(
+
+
+
,
+ );
+
+ const lines = document.querySelectorAll('path.MuiLineElement-root');
+
+ fireEvent.click(lines[0]);
+ expect(onLineClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's1',
+ });
+
+ fireEvent.click(lines[1]);
+ expect(onLineClick.lastCall.args[1]).to.deep.equal({
+ type: 'line',
+ seriesId: 's2',
+ });
+ });
+ });
+});
diff --git a/packages/x-charts/src/PieChart/checkClickEvent.test.tsx b/packages/x-charts/src/PieChart/checkClickEvent.test.tsx
new file mode 100644
index 000000000000..bc5f72d36874
--- /dev/null
+++ b/packages/x-charts/src/PieChart/checkClickEvent.test.tsx
@@ -0,0 +1,74 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { createRenderer, fireEvent } from '@mui/internal-test-utils';
+import { spy } from 'sinon';
+import { PieChart } from '@mui/x-charts/PieChart';
+
+const config = {
+ width: 400,
+ height: 400,
+};
+
+describe('PieChart - click event', () => {
+ const { render } = createRenderer();
+
+ describe('onItemClick', () => {
+ it('should add cursor="pointer" to bar elements', function test() {
+ render(
+ {}}
+ />,
+ );
+ const slices = document.querySelectorAll('path.MuiPieArc-root');
+
+ expect(Array.from(slices).map((slice) => slice.getAttribute('cursor'))).to.deep.equal([
+ 'pointer',
+ 'pointer',
+ ]);
+ });
+
+ it('should provide the right context as second argument', function test() {
+ const onItemClick = spy();
+ render(
+ ,
+ );
+ const slices = document.querySelectorAll('path.MuiPieArc-root');
+
+ fireEvent.click(slices[0]);
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'pie',
+ seriesId: 's1',
+ dataIndex: 0,
+ });
+
+ fireEvent.click(slices[1]);
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'pie',
+ seriesId: 's1',
+ dataIndex: 1,
+ });
+ });
+ });
+});
diff --git a/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx b/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx
new file mode 100644
index 000000000000..08b8148eff60
--- /dev/null
+++ b/packages/x-charts/src/ScatterChart/checkClickEvent.test.tsx
@@ -0,0 +1,183 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { createRenderer, fireEvent } from '@mui/internal-test-utils';
+import { spy } from 'sinon';
+import { ScatterChart } from '@mui/x-charts/ScatterChart';
+
+const config = {
+ dataset: [
+ { id: 1, x: 0, y: 10 },
+ { id: 2, x: 10, y: 10 },
+ { id: 3, x: 10, y: 0 },
+ { id: 4, x: 0, y: 0 },
+ { id: 5, x: 5, y: 5 },
+ ],
+ margin: { top: 0, left: 0, bottom: 0, right: 0 },
+ width: 100,
+ height: 100,
+};
+
+// Plot on series as a dice 5
+//
+// 1...2
+// .....
+// ..5..
+// .....
+// 4...3
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+describe('ScatterChart - click event', () => {
+ const { render } = createRenderer();
+
+ describe('onItemClick - using vornoid', () => {
+ it('should provide the right context as second argument when clicking svg', function test() {
+ if (isJSDOM) {
+ // svg.createSVGPoint not supported by JSDom https://github.com/jsdom/jsdom/issues/300
+ this.skip();
+ }
+ const onItemClick = spy();
+ render(
+
+
+
,
+ );
+ const svg = document.querySelector('svg')!;
+
+ fireEvent.click(svg, {
+ clientX: 10,
+ clientY: 10,
+ });
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'scatter',
+ dataIndex: 0,
+ seriesId: 's1',
+ });
+
+ fireEvent.click(svg, {
+ clientX: 30,
+ clientY: 30,
+ });
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'scatter',
+ dataIndex: 4,
+ seriesId: 's1',
+ });
+
+ expect(onItemClick.callCount).to.equal(2);
+ });
+
+ it('should provide the right context as second argument when clicking mark', function test() {
+ if (isJSDOM) {
+ this.skip();
+ }
+ const onItemClick = spy();
+ render(
+
+
+
,
+ );
+ const marks = document.querySelectorAll('circle');
+
+ fireEvent.click(marks[1], {
+ clientX: 99,
+ clientY: 2,
+ });
+
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'scatter',
+ dataIndex: 1,
+ seriesId: 's1',
+ });
+ expect(onItemClick.callCount).to.equal(1); // Make sure voronoid + item click does not duplicate event triggering
+ });
+ });
+
+ describe('onItemClick - disabling vornoid', () => {
+ it('should not call onItemClick when clicking the SVG', function test() {
+ if (isJSDOM) {
+ this.skip();
+ }
+ const onItemClick = spy();
+ render(
+
+
+
,
+ );
+ const svg = document.querySelector('svg')!;
+
+ fireEvent.click(svg, {
+ clientX: 10,
+ clientY: 10,
+ });
+ expect(onItemClick.callCount).to.equal(0);
+ });
+
+ it('should provide the right context as second argument when clicking mark', function test() {
+ if (isJSDOM) {
+ this.skip();
+ }
+ const onItemClick = spy();
+ render(
+
+
+
,
+ );
+ const marks = document.querySelectorAll('circle');
+
+ fireEvent.click(marks[1], {
+ clientX: 99,
+ clientY: 2,
+ });
+
+ expect(onItemClick.lastCall.args[1]).to.deep.equal({
+ type: 'scatter',
+ dataIndex: 1,
+ seriesId: 's1',
+ });
+ expect(onItemClick.callCount).to.equal(1); // Make sure voronoid + item click does not duplicate event triggering
+ });
+ });
+});
diff --git a/packages/x-charts/src/internals/domUtils.ts b/packages/x-charts/src/internals/domUtils.ts
index d7aca59b459f..e8a1b2e2d119 100644
--- a/packages/x-charts/src/internals/domUtils.ts
+++ b/packages/x-charts/src/internals/domUtils.ts
@@ -45,7 +45,7 @@ const STYLE_LIST = [
'marginTop',
'marginBottom',
];
-const MEASUREMENT_SPAN_ID = 'mui_measurement_span';
+export const MEASUREMENT_SPAN_ID = 'mui_measurement_span';
/**
*
@@ -97,6 +97,7 @@ export const getStyleString = (style: React.CSSProperties) =>
'',
);
+let domCleanTimeout: NodeJS.Timeout | undefined;
/**
*
* @param text The string to estimate
@@ -134,7 +135,6 @@ export const getStringSize = (text: string | number, style: React.CSSProperties
return styleKey;
});
measurementSpan.textContent = str;
-
const rect = measurementSpan.getBoundingClientRect();
const result = { width: rect.width, height: rect.height };
@@ -147,8 +147,22 @@ export const getStringSize = (text: string | number, style: React.CSSProperties
stringCache.cacheCount += 1;
}
+ if (domCleanTimeout) {
+ clearTimeout(domCleanTimeout);
+ }
+ domCleanTimeout = setTimeout(() => {
+ // Limit node cleaning to once per render cycle
+ measurementSpan.textContent = '';
+ }, 0);
+
return result;
} catch (e) {
return { width: 0, height: 0 };
}
};
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export function unstable_cleanupDOM() {
+ // const measurementSpan = document.getElementById(MEASUREMENT_SPAN_ID);
+ // measurementSpan?.remove();
+}
diff --git a/packages/x-charts/src/internals/index.ts b/packages/x-charts/src/internals/index.ts
index f4020f5d0918..d5f2c926fb26 100644
--- a/packages/x-charts/src/internals/index.ts
+++ b/packages/x-charts/src/internals/index.ts
@@ -21,6 +21,7 @@ export * from './configInit';
export * from './getLabel';
export * from './getSVGPoint';
export * from './isDefined';
+export { unstable_cleanupDOM } from './domUtils';
// contexts
diff --git a/packages/x-charts/src/tests/firePointerEvent.ts b/packages/x-charts/src/tests/firePointerEvent.ts
new file mode 100644
index 000000000000..ae639a28fcfc
--- /dev/null
+++ b/packages/x-charts/src/tests/firePointerEvent.ts
@@ -0,0 +1,41 @@
+import { fireEvent } from '@mui/internal-test-utils';
+
+export function firePointerEvent(
+ target: Element,
+ type: 'pointerstart' | 'pointermove' | 'pointerend',
+ options: Pick,
+): void {
+ const originalGetBoundingClientRect = target.getBoundingClientRect;
+ target.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ toJSON() {
+ return {
+ x: 0,
+ y: 0,
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ };
+ },
+ });
+ const event = new window.PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ isPrimary: true,
+ ...options,
+ });
+
+ fireEvent(target, event);
+ target.getBoundingClientRect = originalGetBoundingClientRect;
+}
diff --git a/test/utils/mochaHooks.js b/test/utils/mochaHooks.js
index b5f113969c06..5e774ee1be5c 100644
--- a/test/utils/mochaHooks.js
+++ b/test/utils/mochaHooks.js
@@ -2,6 +2,7 @@ import sinon from 'sinon';
import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingDataGrid } from '@mui/x-data-grid';
import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingDataGridPro } from '@mui/x-data-grid-pro';
import { unstable_resetCleanupTracking as unstable_resetCleanupTrackingTreeView } from '@mui/x-tree-view';
+import { unstable_cleanupDOM as unstable_cleanupDOMCharts } from '@mui/x-charts/internals';
import { clearWarningsCache } from '@mui/x-data-grid/internals';
import { generateTestLicenseKey, setupTestLicenseKey } from './testLicense';
@@ -27,6 +28,7 @@ export function createXMochaHooks(coreMochaHooks = {}) {
unstable_resetCleanupTrackingDataGrid();
unstable_resetCleanupTrackingDataGridPro();
unstable_resetCleanupTrackingTreeView();
+ unstable_cleanupDOMCharts();
// Restore Sinon default sandbox to avoid memory leak
// See https://github.com/sinonjs/sinon/issues/1866