Skip to content

Commit f986dc9

Browse files
authored
Merge pull request #376 from plotly/cam/375/add-missing-events
feat: Add missing plotly.js events
2 parents 7c1c576 + 8b353e8 commit f986dc9

8 files changed

Lines changed: 251 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ where X.Y.Z is the semver of most recent react-plotly.js release.
1111

1212
## [Unreleased]
1313

14+
### Added
15+
16+
- 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)]
17+
- Added additional missing events: `onLegendTitleClick`, `onLegendTitleDoubleClick`, `onBeforePlot`, `onAnimating`, and `onTransitioned` [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
18+
19+
### Fixed
20+
21+
- Returning `false` from `onSunburstClick`, `onTreemapClick`, or `onIcicleClick` now prevents the drill-down [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
22+
- `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
23+
24+
### Removed
25+
26+
- Removed the errantly added `onClickAnywhere` and `onHoverAnywhere` props [[#376](https://github.com/plotly/react-plotly.js/pull/376)]
27+
- plotly.js has no `plotly_clickanywhere` or `plotly_hoveranywhere` events, so these never fired
28+
- `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
29+
1430
## [4.0.0] - 2026-06-18
1531

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

3955
### Added
4056

41-
- `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)]
57+
- `onClickAnywhere` and `onHoverAnywhere` event props [[#360](https://github.com/plotly/react-plotly.js/pull/360)]
58+
- **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.
4259

4360
### Changed
4461

README.md

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -152,41 +152,60 @@ The `onInitialized`, `onUpdate` and `onPurge` props are all functions which will
152152

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

155-
| Prop | Type | Plotly Event |
156-
| ------------------------- | ---------- | ------------------------------ |
157-
| `onAfterExport` | `Function` | `plotly_afterexport` |
158-
| `onAfterPlot` | `Function` | `plotly_afterplot` |
159-
| `onAnimated` | `Function` | `plotly_animated` |
160-
| `onAnimatingFrame` | `Function` | `plotly_animatingframe` |
161-
| `onAnimationInterrupted` | `Function` | `plotly_animationinterrupted` |
162-
| `onAutoSize` | `Function` | `plotly_autosize` |
163-
| `onBeforeExport` | `Function` | `plotly_beforeexport` |
164-
| `onBeforeHover` | `Function` | `plotly_beforehover` |
165-
| `onButtonClicked` | `Function` | `plotly_buttonclicked` |
166-
| `onClick` | `Function` | `plotly_click` |
167-
| `onClickAnnotation` | `Function` | `plotly_clickannotation` |
168-
| `onClickAnywhere` | `Function` | `plotly_clickanywhere` |
169-
| `onDeselect` | `Function` | `plotly_deselect` |
170-
| `onDoubleClick` | `Function` | `plotly_doubleclick` |
171-
| `onFramework` | `Function` | `plotly_framework` |
172-
| `onHover` | `Function` | `plotly_hover` |
173-
| `onHoverAnywhere` | `Function` | `plotly_hoveranywhere` |
174-
| `onLegendClick` | `Function` | `plotly_legendclick` |
175-
| `onLegendDoubleClick` | `Function` | `plotly_legenddoubleclick` |
176-
| `onRelayout` | `Function` | `plotly_relayout` |
177-
| `onRelayouting` | `Function` | `plotly_relayouting` |
178-
| `onRestyle` | `Function` | `plotly_restyle` |
179-
| `onRedraw` | `Function` | `plotly_redraw` |
180-
| `onSelected` | `Function` | `plotly_selected` |
181-
| `onSelecting` | `Function` | `plotly_selecting` |
182-
| `onSliderChange` | `Function` | `plotly_sliderchange` |
183-
| `onSliderEnd` | `Function` | `plotly_sliderend` |
184-
| `onSliderStart` | `Function` | `plotly_sliderstart` |
185-
| `onSunburstClick` | `Function` | `plotly_sunburstclick` |
186-
| `onTransitioning` | `Function` | `plotly_transitioning` |
187-
| `onTransitionInterrupted` | `Function` | `plotly_transitioninterrupted` |
188-
| `onUnhover` | `Function` | `plotly_unhover` |
189-
| `onWebGlContextLost` | `Function` | `plotly_webglcontextlost` |
155+
| Prop | Type | Plotly Event |
156+
| -------------------------- | ---------- | ------------------------------- |
157+
| `onAfterExport` | `Function` | `plotly_afterexport` |
158+
| `onAfterPlot` | `Function` | `plotly_afterplot` |
159+
| `onAnimated` | `Function` | `plotly_animated` |
160+
| `onAnimating` | `Function` | `plotly_animating` |
161+
| `onAnimatingFrame` | `Function` | `plotly_animatingframe` |
162+
| `onAnimationInterrupted` | `Function` | `plotly_animationinterrupted` |
163+
| `onAutoSize` | `Function` | `plotly_autosize` |
164+
| `onBeforeExport` | `Function` | `plotly_beforeexport` |
165+
| `onBeforeHover` | `Function` | `plotly_beforehover` |
166+
| `onBeforePlot` | `Function` | `plotly_beforeplot` |
167+
| `onButtonClicked` | `Function` | `plotly_buttonclicked` |
168+
| `onClick` | `Function` | `plotly_click` |
169+
| `onClickAnnotation` | `Function` | `plotly_clickannotation` |
170+
| `onDeselect` | `Function` | `plotly_deselect` |
171+
| `onDoubleClick` | `Function` | `plotly_doubleclick` |
172+
| `onFramework` | `Function` | `plotly_framework` |
173+
| `onHover` | `Function` | `plotly_hover` |
174+
| `onIcicleClick` | `Function` | `plotly_icicleclick` |
175+
| `onLegendClick` | `Function` | `plotly_legendclick` |
176+
| `onLegendDoubleClick` | `Function` | `plotly_legenddoubleclick` |
177+
| `onLegendTitleClick` | `Function` | `plotly_legendtitleclick` |
178+
| `onLegendTitleDoubleClick` | `Function` | `plotly_legendtitledoubleclick` |
179+
| `onRelayout` | `Function` | `plotly_relayout` |
180+
| `onRelayouting` | `Function` | `plotly_relayouting` |
181+
| `onRestyle` | `Function` | `plotly_restyle` |
182+
| `onRedraw` | `Function` | `plotly_redraw` |
183+
| `onSelected` | `Function` | `plotly_selected` |
184+
| `onSelecting` | `Function` | `plotly_selecting` |
185+
| `onSliderChange` | `Function` | `plotly_sliderchange` |
186+
| `onSliderEnd` | `Function` | `plotly_sliderend` |
187+
| `onSliderStart` | `Function` | `plotly_sliderstart` |
188+
| `onSunburstClick` | `Function` | `plotly_sunburstclick` |
189+
| `onTransitioned` | `Function` | `plotly_transitioned` |
190+
| `onTransitioning` | `Function` | `plotly_transitioning` |
191+
| `onTransitionInterrupted` | `Function` | `plotly_transitioninterrupted` |
192+
| `onTreemapClick` | `Function` | `plotly_treemapclick` |
193+
| `onUnhover` | `Function` | `plotly_unhover` |
194+
| `onWebGlContextLost` | `Function` | `plotly_webglcontextlost` |
195+
196+
To receive `onClick` or `onHover` for positions that are not over a trace, set
197+
`clickanywhere` or `hoveranywhere` in your `layout`. These are layout attributes
198+
rather than distinct events: the ordinary `plotly_click` / `plotly_hover` fire
199+
with an empty `points` array, plus `xvals` / `yvals` — arrays of the cursor
200+
position in data space, one entry per axis.
201+
202+
```javascript
203+
<Plot
204+
data={data}
205+
layout={{clickanywhere: true}}
206+
onClick={(e) => console.log(e.points, e.xvals, e.yvals)}
207+
/>
208+
```
190209

191210
## Examples
192211

src/__mocks__/plotly.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ const state = {};
33

44
const ASYNC_DELAY = 1;
55

6+
// Real plotly.js binds node's EventEmitter onto the graph div, so a gd exposes
7+
// `removeListener` (see plotly.js src/lib/events.js). The `event-emitter`
8+
// package only gives us `off`, and the wrapper treats a missing
9+
// `removeListener` as "not a plotly graph div" — which silently disabled the
10+
// whole update-event path under test. Alias it so the mock matches reality.
11+
function attachEmitter(gd) {
12+
EventEmitter(gd); // eslint-disable-line new-cap
13+
gd.removeListener = gd.off;
14+
return gd;
15+
}
16+
617
export default {
718
plot: jest.fn((gd) => {
819
state.gd = gd;
@@ -11,16 +22,14 @@ export default {
1122
}, ASYNC_DELAY);
1223
}),
1324
newPlot: jest.fn((gd) => {
14-
state.gd = gd;
15-
EventEmitter(state.gd); // eslint-disable-line new-cap
25+
state.gd = attachEmitter(gd);
1626

1727
setTimeout(() => {
1828
state.gd.emit('plotly_afterplot');
1929
}, ASYNC_DELAY);
2030
}),
2131
react: jest.fn((gd) => {
22-
state.gd = gd;
23-
EventEmitter(state.gd); // eslint-disable-line new-cap
32+
state.gd = attachEmitter(gd);
2433

2534
setTimeout(() => {
2635
state.gd.emit('plotly_afterplot');

src/__tests__/events.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {readFileSync} from 'node:fs';
2+
import {join} from 'node:path';
3+
import {events, eventNames, getPlotlyEventName, getPropName, updateEvents} from '../events';
4+
5+
// `events` drives the runtime wiring. The two hand-maintained copies — the
6+
// `on*` props in `factory.d.ts` and the README table — can't be derived from
7+
// it, so they're checked here instead. The declarations are shipped to
8+
// consumers but unused by this library, which is why a text comparison is
9+
// enough; nothing here needs the type system.
10+
describe('events', () => {
11+
test('has no duplicate event names', () => {
12+
expect(new Set(eventNames).size).toBe(eventNames.length);
13+
});
14+
15+
test('every update event is one of the forwarded events', () => {
16+
const forwarded = eventNames.map(getPlotlyEventName);
17+
expect(updateEvents.length).toBeGreaterThan(0);
18+
expect(forwarded).toEqual(expect.arrayContaining(updateEvents));
19+
});
20+
21+
test('PlotParams declares a prop for every event', () => {
22+
const dts = readFileSync(join(__dirname, '..', 'factory.d.ts'), 'utf8');
23+
24+
// Lines look like: onAfterPlot?: EventCallback;
25+
const propPattern = /^\s*(on\w+)\?: EventCallback;$/gm;
26+
const declared = [...dts.matchAll(propPattern)].map(([, prop]) => prop);
27+
28+
expect(declared).toEqual(events.map((event) => getPropName(event.name)));
29+
});
30+
31+
test('README table matches the event list', () => {
32+
const readme = readFileSync(join(__dirname, '..', '..', 'README.md'), 'utf8');
33+
34+
// Rows look like: | `onAfterPlot` | `Function` | `plotly_afterplot` |
35+
const rowPattern = /^\|\s*`(on\w+)`\s*\|\s*`Function`\s*\|\s*`(plotly_\w+)`\s*\|$/gm;
36+
const documented = [...readme.matchAll(rowPattern)].map(([, prop, event]) => [prop, event]);
37+
38+
const expected = events.map((event) => [
39+
getPropName(event.name),
40+
getPlotlyEventName(event.name),
41+
]);
42+
43+
expect(documented).toEqual(expected);
44+
});
45+
});

src/__tests__/react-plotly.test.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, {StrictMode, useState} from 'react';
33
import {act, render} from '@testing-library/react';
44
import createComponent from '../factory';
5+
import {eventNames, getPlotlyEventName, getPropName} from '../events';
56
import once from 'onetime';
67

78
describe('<Plotly/>', () => {
@@ -185,7 +186,7 @@ describe('<Plotly/>', () => {
185186
});
186187
});
187188

188-
describe('manging event handlers', () => {
189+
describe('managing event handlers', () => {
189190
test('should add an event handler when one does not already exist', (done) => {
190191
let received;
191192
const onRelayout = (evt) => {
@@ -203,6 +204,45 @@ describe('<Plotly/>', () => {
203204
})
204205
.catch((err) => done(err));
205206
});
207+
208+
// Every forwarded event should reach its `on*` prop. Driving this from
209+
// the event list means a new entry is covered without touching the test.
210+
test.each(eventNames)('forwards plotly_%s to its prop', (eventName) => {
211+
let received;
212+
const handler = (evt) => {
213+
received = evt;
214+
};
215+
216+
return createPlot({[getPropName(eventName)]: handler}).then((plot) => {
217+
plot.gd.emit(getPlotlyEventName(eventName), {eventName});
218+
expect(received).toEqual({eventName});
219+
});
220+
});
221+
222+
// Cancelable — the consumer's handler must be the only listener or
223+
// plotly could drop its `return false`. See the note in `events.js`.
224+
test.each(['SunburstClick', 'TreemapClick', 'IcicleClick'])(
225+
'attaches only the consumer handler to %s',
226+
(eventName) => {
227+
const handler = () => false;
228+
229+
return createPlot({[getPropName(eventName)]: handler, onUpdate: () => {}}).then(
230+
(plot) => {
231+
const listeners = [].concat(plot.gd.__ee__[getPlotlyEventName(eventName)] || []);
232+
expect(listeners).toEqual([handler]);
233+
}
234+
);
235+
}
236+
);
237+
238+
test('fires onUpdate when a drill-down animation completes', () => {
239+
const onUpdate = jest.fn();
240+
241+
return createPlot({onUpdate}).then((plot) => {
242+
plot.gd.emit('plotly_animated');
243+
expect(onUpdate).toHaveBeenCalled();
244+
});
245+
});
206246
});
207247

208248
describe('StrictMode', () => {

src/events.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// The single source of truth for the plotly.js events this wrapper forwards.
2+
//
3+
// The naming convention is:
4+
// - events are attached as `'plotly_' + name.toLowerCase()`
5+
// - react props are `'on' + name`
6+
//
7+
// `triggersUpdate` marks events plotly.js emits *after* changing the figure;
8+
// the wrapper listens to those and fires `onUpdate`. Never set it on a
9+
// cancelable event — that adds a second listener alongside the consumer's,
10+
// and plotly keeps only the last listener's return value, so a consumer's
11+
// `return false` could be discarded. The drill-down clicks rely on
12+
// `plotly_animated` instead.
13+
//
14+
// The `on*` prop types in `factory.d.ts` are maintained by hand against this list
15+
export const events = [
16+
{name: 'AfterExport'},
17+
{name: 'AfterPlot'},
18+
{name: 'Animated', triggersUpdate: true},
19+
{name: 'Animating'},
20+
{name: 'AnimatingFrame'},
21+
{name: 'AnimationInterrupted'},
22+
{name: 'AutoSize'},
23+
{name: 'BeforeExport'},
24+
{name: 'BeforeHover'},
25+
{name: 'BeforePlot'},
26+
{name: 'ButtonClicked'},
27+
{name: 'Click'},
28+
{name: 'ClickAnnotation'},
29+
{name: 'Deselect'},
30+
{name: 'DoubleClick', triggersUpdate: true},
31+
{name: 'Framework'},
32+
{name: 'Hover'},
33+
{name: 'IcicleClick'},
34+
{name: 'LegendClick'},
35+
{name: 'LegendDoubleClick'},
36+
{name: 'LegendTitleClick'},
37+
{name: 'LegendTitleDoubleClick'},
38+
{name: 'Relayout', triggersUpdate: true},
39+
{name: 'Relayouting', triggersUpdate: true},
40+
{name: 'Restyle', triggersUpdate: true},
41+
{name: 'Redraw', triggersUpdate: true},
42+
{name: 'Selected'},
43+
{name: 'Selecting'},
44+
{name: 'SliderChange'},
45+
{name: 'SliderEnd'},
46+
{name: 'SliderStart'},
47+
{name: 'SunburstClick'},
48+
{name: 'Transitioned'},
49+
{name: 'Transitioning'},
50+
{name: 'TransitionInterrupted'},
51+
{name: 'TreemapClick'},
52+
{name: 'Unhover'},
53+
{name: 'WebGlContextLost'},
54+
];
55+
56+
/** The plotly.js event name a given entry is attached as. */
57+
export function getPlotlyEventName(eventName) {
58+
return 'plotly_' + eventName.toLowerCase();
59+
}
60+
61+
/** The React prop name a given entry is read from. */
62+
export function getPropName(eventName) {
63+
return 'on' + eventName;
64+
}
65+
66+
export const eventNames = events.map((event) => event.name);
67+
68+
export const updateEvents = events
69+
.filter((event) => event.triggersUpdate)
70+
.map((event) => getPlotlyEventName(event.name));

src/factory.d.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,25 @@ export interface PlotParams {
3535
onAfterExport?: EventCallback;
3636
onAfterPlot?: EventCallback;
3737
onAnimated?: EventCallback;
38+
onAnimating?: EventCallback;
3839
onAnimatingFrame?: EventCallback;
3940
onAnimationInterrupted?: EventCallback;
4041
onAutoSize?: EventCallback;
4142
onBeforeExport?: EventCallback;
4243
onBeforeHover?: EventCallback;
44+
onBeforePlot?: EventCallback;
4345
onButtonClicked?: EventCallback;
4446
onClick?: EventCallback;
4547
onClickAnnotation?: EventCallback;
46-
onClickAnywhere?: EventCallback;
4748
onDeselect?: EventCallback;
4849
onDoubleClick?: EventCallback;
4950
onFramework?: EventCallback;
5051
onHover?: EventCallback;
51-
onHoverAnywhere?: EventCallback;
52+
onIcicleClick?: EventCallback;
5253
onLegendClick?: EventCallback;
5354
onLegendDoubleClick?: EventCallback;
55+
onLegendTitleClick?: EventCallback;
56+
onLegendTitleDoubleClick?: EventCallback;
5457
onRelayout?: EventCallback;
5558
onRelayouting?: EventCallback;
5659
onRestyle?: EventCallback;
@@ -61,8 +64,10 @@ export interface PlotParams {
6164
onSliderEnd?: EventCallback;
6265
onSliderStart?: EventCallback;
6366
onSunburstClick?: EventCallback;
67+
onTransitioned?: EventCallback;
6468
onTransitioning?: EventCallback;
6569
onTransitionInterrupted?: EventCallback;
70+
onTreemapClick?: EventCallback;
6671
onUnhover?: EventCallback;
6772
onWebGlContextLost?: EventCallback;
6873
}
@@ -78,8 +83,6 @@ export interface PlotParams {
7883
*/
7984
declare function createPlotlyComponent(
8085
Plotly: unknown
81-
): React.ForwardRefExoticComponent<
82-
PlotParams & React.RefAttributes<HTMLDivElement>
83-
>;
86+
): React.ForwardRefExoticComponent<PlotParams & React.RefAttributes<HTMLDivElement>>;
8487

8588
export default createPlotlyComponent;

0 commit comments

Comments
 (0)