Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ where X.Y.Z is the semver of most recent react-plotly.js release.

## [Unreleased]

### Added

- Added missing `onTreemapClick` and `onIcicleClick` event props for the `plotly_treemapclick` and `plotly_icicleclick` events [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
- Added additional missing events: `onLegendTitleClick`, `onLegendTitleDoubleClick`, `onBeforePlot`, `onAnimating`, and `onTransitioned` [[#376](https://github.com/plotly/react-plotly.js/pull/376)]

### Fixed

- Returning `false` from `onSunburstClick`, `onTreemapClick`, or `onIcicleClick` now prevents the drill-down [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
- `onUpdate` now fires for these via `plotly_animated`, so it reports the figure after the new `level` is applied and no longer fires when the drill-down is cancelled

### Removed

- Removed the errantly added `onClickAnywhere` and `onHoverAnywhere` props [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
- plotly.js has no `plotly_clickanywhere` or `plotly_hoveranywhere` events, so these never fired
- `clickanywhere` and `hoveranywhere` are **layout attributes** that widen the ordinary `plotly_click` / `plotly_hover`, so the feature is reached through `onClick` / `onHover` with the layout flag set — see the README

## [4.0.0] - 2026-06-18

### Added
Expand Down Expand Up @@ -38,7 +54,8 @@ where X.Y.Z is the semver of most recent react-plotly.js release.

### Added

- `onClickAnywhere` and `onHoverAnywhere` event props for the corresponding `plotly_clickanywhere` and `plotly_hoveranywhere` events introduced in plotly.js v3 [[#360](https://github.com/plotly/react-plotly.js/pull/360)]
- `onClickAnywhere` and `onHoverAnywhere` event props [[#360](https://github.com/plotly/react-plotly.js/pull/360)]
- **Correction:** plotly.js v3 introduced `clickanywhere` / `hoveranywhere` as _layout attributes_, not as events, so these props never fired. They are removed in the next release.

### Changed

Expand Down
89 changes: 54 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,41 +152,60 @@ The `onInitialized`, `onUpdate` and `onPurge` props are all functions which will

Event handlers for specific [`plotly.js` events](https://plotly.com/javascript/plotlyjs-events/) may be attached through the following props:

| Prop | Type | Plotly Event |
| ------------------------- | ---------- | ------------------------------ |
| `onAfterExport` | `Function` | `plotly_afterexport` |
| `onAfterPlot` | `Function` | `plotly_afterplot` |
| `onAnimated` | `Function` | `plotly_animated` |
| `onAnimatingFrame` | `Function` | `plotly_animatingframe` |
| `onAnimationInterrupted` | `Function` | `plotly_animationinterrupted` |
| `onAutoSize` | `Function` | `plotly_autosize` |
| `onBeforeExport` | `Function` | `plotly_beforeexport` |
| `onBeforeHover` | `Function` | `plotly_beforehover` |
| `onButtonClicked` | `Function` | `plotly_buttonclicked` |
| `onClick` | `Function` | `plotly_click` |
| `onClickAnnotation` | `Function` | `plotly_clickannotation` |
| `onClickAnywhere` | `Function` | `plotly_clickanywhere` |
| `onDeselect` | `Function` | `plotly_deselect` |
| `onDoubleClick` | `Function` | `plotly_doubleclick` |
| `onFramework` | `Function` | `plotly_framework` |
| `onHover` | `Function` | `plotly_hover` |
| `onHoverAnywhere` | `Function` | `plotly_hoveranywhere` |
| `onLegendClick` | `Function` | `plotly_legendclick` |
| `onLegendDoubleClick` | `Function` | `plotly_legenddoubleclick` |
| `onRelayout` | `Function` | `plotly_relayout` |
| `onRelayouting` | `Function` | `plotly_relayouting` |
| `onRestyle` | `Function` | `plotly_restyle` |
| `onRedraw` | `Function` | `plotly_redraw` |
| `onSelected` | `Function` | `plotly_selected` |
| `onSelecting` | `Function` | `plotly_selecting` |
| `onSliderChange` | `Function` | `plotly_sliderchange` |
| `onSliderEnd` | `Function` | `plotly_sliderend` |
| `onSliderStart` | `Function` | `plotly_sliderstart` |
| `onSunburstClick` | `Function` | `plotly_sunburstclick` |
| `onTransitioning` | `Function` | `plotly_transitioning` |
| `onTransitionInterrupted` | `Function` | `plotly_transitioninterrupted` |
| `onUnhover` | `Function` | `plotly_unhover` |
| `onWebGlContextLost` | `Function` | `plotly_webglcontextlost` |
| Prop | Type | Plotly Event |
| -------------------------- | ---------- | ------------------------------- |
| `onAfterExport` | `Function` | `plotly_afterexport` |
| `onAfterPlot` | `Function` | `plotly_afterplot` |
| `onAnimated` | `Function` | `plotly_animated` |
| `onAnimating` | `Function` | `plotly_animating` |
| `onAnimatingFrame` | `Function` | `plotly_animatingframe` |
| `onAnimationInterrupted` | `Function` | `plotly_animationinterrupted` |
| `onAutoSize` | `Function` | `plotly_autosize` |
| `onBeforeExport` | `Function` | `plotly_beforeexport` |
| `onBeforeHover` | `Function` | `plotly_beforehover` |
| `onBeforePlot` | `Function` | `plotly_beforeplot` |
| `onButtonClicked` | `Function` | `plotly_buttonclicked` |
| `onClick` | `Function` | `plotly_click` |
| `onClickAnnotation` | `Function` | `plotly_clickannotation` |
| `onDeselect` | `Function` | `plotly_deselect` |
| `onDoubleClick` | `Function` | `plotly_doubleclick` |
| `onFramework` | `Function` | `plotly_framework` |
| `onHover` | `Function` | `plotly_hover` |
| `onIcicleClick` | `Function` | `plotly_icicleclick` |
| `onLegendClick` | `Function` | `plotly_legendclick` |
| `onLegendDoubleClick` | `Function` | `plotly_legenddoubleclick` |
| `onLegendTitleClick` | `Function` | `plotly_legendtitleclick` |
| `onLegendTitleDoubleClick` | `Function` | `plotly_legendtitledoubleclick` |
| `onRelayout` | `Function` | `plotly_relayout` |
| `onRelayouting` | `Function` | `plotly_relayouting` |
| `onRestyle` | `Function` | `plotly_restyle` |
| `onRedraw` | `Function` | `plotly_redraw` |
| `onSelected` | `Function` | `plotly_selected` |
| `onSelecting` | `Function` | `plotly_selecting` |
| `onSliderChange` | `Function` | `plotly_sliderchange` |
| `onSliderEnd` | `Function` | `plotly_sliderend` |
| `onSliderStart` | `Function` | `plotly_sliderstart` |
| `onSunburstClick` | `Function` | `plotly_sunburstclick` |
| `onTransitioned` | `Function` | `plotly_transitioned` |
| `onTransitioning` | `Function` | `plotly_transitioning` |
| `onTransitionInterrupted` | `Function` | `plotly_transitioninterrupted` |
| `onTreemapClick` | `Function` | `plotly_treemapclick` |
| `onUnhover` | `Function` | `plotly_unhover` |
| `onWebGlContextLost` | `Function` | `plotly_webglcontextlost` |

To receive `onClick` or `onHover` for positions that are not over a trace, set
`clickanywhere` or `hoveranywhere` in your `layout`. These are layout attributes
rather than distinct events: the ordinary `plotly_click` / `plotly_hover` fire
with an empty `points` array, plus `xvals` / `yvals` — arrays of the cursor
position in data space, one entry per axis.

```javascript
<Plot
data={data}
layout={{clickanywhere: true}}
onClick={(e) => console.log(e.points, e.xvals, e.yvals)}
/>
```

## Examples

Expand Down
17 changes: 13 additions & 4 deletions src/__mocks__/plotly.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ const state = {};

const ASYNC_DELAY = 1;

// Real plotly.js binds node's EventEmitter onto the graph div, so a gd exposes
// `removeListener` (see plotly.js src/lib/events.js). The `event-emitter`
// package only gives us `off`, and the wrapper treats a missing
// `removeListener` as "not a plotly graph div" — which silently disabled the
// whole update-event path under test. Alias it so the mock matches reality.
function attachEmitter(gd) {
EventEmitter(gd); // eslint-disable-line new-cap
gd.removeListener = gd.off;
return gd;
}

export default {
plot: jest.fn((gd) => {
state.gd = gd;
Expand All @@ -11,16 +22,14 @@ export default {
}, ASYNC_DELAY);
}),
newPlot: jest.fn((gd) => {
state.gd = gd;
EventEmitter(state.gd); // eslint-disable-line new-cap
state.gd = attachEmitter(gd);

setTimeout(() => {
state.gd.emit('plotly_afterplot');
}, ASYNC_DELAY);
}),
react: jest.fn((gd) => {
state.gd = gd;
EventEmitter(state.gd); // eslint-disable-line new-cap
state.gd = attachEmitter(gd);

setTimeout(() => {
state.gd.emit('plotly_afterplot');
Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/events.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {readFileSync} from 'node:fs';
import {join} from 'node:path';
import {events, eventNames, getPlotlyEventName, getPropName, updateEvents} from '../events';

// `events` drives the runtime wiring. The two hand-maintained copies — the
// `on*` props in `factory.d.ts` and the README table — can't be derived from
// it, so they're checked here instead. The declarations are shipped to
// consumers but unused by this library, which is why a text comparison is
// enough; nothing here needs the type system.
describe('events', () => {
test('has no duplicate event names', () => {
expect(new Set(eventNames).size).toBe(eventNames.length);
});

test('every update event is one of the forwarded events', () => {
const forwarded = eventNames.map(getPlotlyEventName);
expect(updateEvents.length).toBeGreaterThan(0);
expect(forwarded).toEqual(expect.arrayContaining(updateEvents));
});

test('PlotParams declares a prop for every event', () => {
const dts = readFileSync(join(__dirname, '..', 'factory.d.ts'), 'utf8');

// Lines look like: onAfterPlot?: EventCallback;
const propPattern = /^\s*(on\w+)\?: EventCallback;$/gm;
const declared = [...dts.matchAll(propPattern)].map(([, prop]) => prop);

expect(declared).toEqual(events.map((event) => getPropName(event.name)));
});

test('README table matches the event list', () => {
const readme = readFileSync(join(__dirname, '..', '..', 'README.md'), 'utf8');

// Rows look like: | `onAfterPlot` | `Function` | `plotly_afterplot` |
const rowPattern = /^\|\s*`(on\w+)`\s*\|\s*`Function`\s*\|\s*`(plotly_\w+)`\s*\|$/gm;
const documented = [...readme.matchAll(rowPattern)].map(([, prop, event]) => [prop, event]);

const expected = events.map((event) => [
getPropName(event.name),
getPlotlyEventName(event.name),
]);

expect(documented).toEqual(expected);
});
});
42 changes: 41 additions & 1 deletion src/__tests__/react-plotly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React, {StrictMode, useState} from 'react';
import {act, render} from '@testing-library/react';
import createComponent from '../factory';
import {eventNames, getPlotlyEventName, getPropName} from '../events';
import once from 'onetime';

describe('<Plotly/>', () => {
Expand Down Expand Up @@ -185,7 +186,7 @@ describe('<Plotly/>', () => {
});
});

describe('manging event handlers', () => {
describe('managing event handlers', () => {
test('should add an event handler when one does not already exist', (done) => {
let received;
const onRelayout = (evt) => {
Expand All @@ -203,6 +204,45 @@ describe('<Plotly/>', () => {
})
.catch((err) => done(err));
});

// Every forwarded event should reach its `on*` prop. Driving this from
// the event list means a new entry is covered without touching the test.
test.each(eventNames)('forwards plotly_%s to its prop', (eventName) => {
let received;
const handler = (evt) => {
received = evt;
};

return createPlot({[getPropName(eventName)]: handler}).then((plot) => {
plot.gd.emit(getPlotlyEventName(eventName), {eventName});
expect(received).toEqual({eventName});
});
});

// Cancelable — the consumer's handler must be the only listener or
// plotly could drop its `return false`. See the note in `events.js`.
test.each(['SunburstClick', 'TreemapClick', 'IcicleClick'])(
'attaches only the consumer handler to %s',
(eventName) => {
const handler = () => false;

return createPlot({[getPropName(eventName)]: handler, onUpdate: () => {}}).then(
(plot) => {
const listeners = [].concat(plot.gd.__ee__[getPlotlyEventName(eventName)] || []);
expect(listeners).toEqual([handler]);
}
);
}
);

test('fires onUpdate when a drill-down animation completes', () => {
const onUpdate = jest.fn();

return createPlot({onUpdate}).then((plot) => {
plot.gd.emit('plotly_animated');
expect(onUpdate).toHaveBeenCalled();
});
});
});

describe('StrictMode', () => {
Expand Down
70 changes: 70 additions & 0 deletions src/events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// The single source of truth for the plotly.js events this wrapper forwards.
//
// The naming convention is:
// - events are attached as `'plotly_' + name.toLowerCase()`
// - react props are `'on' + name`
//
// `triggersUpdate` marks events plotly.js emits *after* changing the figure;
// the wrapper listens to those and fires `onUpdate`. Never set it on a
// cancelable event — that adds a second listener alongside the consumer's,
// and plotly keeps only the last listener's return value, so a consumer's
// `return false` could be discarded. The drill-down clicks rely on
// `plotly_animated` instead.
//
// The `on*` prop types in `factory.d.ts` are maintained by hand against this list
export const events = [
{name: 'AfterExport'},
{name: 'AfterPlot'},
{name: 'Animated', triggersUpdate: true},
{name: 'Animating'},
{name: 'AnimatingFrame'},
{name: 'AnimationInterrupted'},
{name: 'AutoSize'},
{name: 'BeforeExport'},
{name: 'BeforeHover'},
{name: 'BeforePlot'},
{name: 'ButtonClicked'},
{name: 'Click'},
{name: 'ClickAnnotation'},
{name: 'Deselect'},
{name: 'DoubleClick', triggersUpdate: true},
{name: 'Framework'},
{name: 'Hover'},
{name: 'IcicleClick'},
{name: 'LegendClick'},
{name: 'LegendDoubleClick'},
{name: 'LegendTitleClick'},
{name: 'LegendTitleDoubleClick'},
{name: 'Relayout', triggersUpdate: true},
{name: 'Relayouting', triggersUpdate: true},
{name: 'Restyle', triggersUpdate: true},
{name: 'Redraw', triggersUpdate: true},
{name: 'Selected'},
{name: 'Selecting'},
{name: 'SliderChange'},
{name: 'SliderEnd'},
{name: 'SliderStart'},
{name: 'SunburstClick'},
{name: 'Transitioned'},
{name: 'Transitioning'},
{name: 'TransitionInterrupted'},
{name: 'TreemapClick'},
{name: 'Unhover'},
{name: 'WebGlContextLost'},
];

/** The plotly.js event name a given entry is attached as. */
export function getPlotlyEventName(eventName) {
return 'plotly_' + eventName.toLowerCase();
}

/** The React prop name a given entry is read from. */
export function getPropName(eventName) {
return 'on' + eventName;
}

export const eventNames = events.map((event) => event.name);

export const updateEvents = events
.filter((event) => event.triggersUpdate)
.map((event) => getPlotlyEventName(event.name));
13 changes: 8 additions & 5 deletions src/factory.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,25 @@ export interface PlotParams {
onAfterExport?: EventCallback;
onAfterPlot?: EventCallback;
onAnimated?: EventCallback;
onAnimating?: EventCallback;
onAnimatingFrame?: EventCallback;
onAnimationInterrupted?: EventCallback;
onAutoSize?: EventCallback;
onBeforeExport?: EventCallback;
onBeforeHover?: EventCallback;
onBeforePlot?: EventCallback;
onButtonClicked?: EventCallback;
onClick?: EventCallback;
onClickAnnotation?: EventCallback;
onClickAnywhere?: EventCallback;
onDeselect?: EventCallback;
onDoubleClick?: EventCallback;
onFramework?: EventCallback;
onHover?: EventCallback;
onHoverAnywhere?: EventCallback;
onIcicleClick?: EventCallback;
onLegendClick?: EventCallback;
onLegendDoubleClick?: EventCallback;
onLegendTitleClick?: EventCallback;
onLegendTitleDoubleClick?: EventCallback;
onRelayout?: EventCallback;
onRelayouting?: EventCallback;
onRestyle?: EventCallback;
Expand All @@ -61,8 +64,10 @@ export interface PlotParams {
onSliderEnd?: EventCallback;
onSliderStart?: EventCallback;
onSunburstClick?: EventCallback;
onTransitioned?: EventCallback;
onTransitioning?: EventCallback;
onTransitionInterrupted?: EventCallback;
onTreemapClick?: EventCallback;
onUnhover?: EventCallback;
onWebGlContextLost?: EventCallback;
}
Expand All @@ -78,8 +83,6 @@ export interface PlotParams {
*/
declare function createPlotlyComponent(
Plotly: unknown
): React.ForwardRefExoticComponent<
PlotParams & React.RefAttributes<HTMLDivElement>
>;
): React.ForwardRefExoticComponent<PlotParams & React.RefAttributes<HTMLDivElement>>;

export default createPlotlyComponent;
Loading
Loading