diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a68fe..f8f7053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 16ee701..2a5a170 100644 --- a/README.md +++ b/README.md @@ -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 + console.log(e.points, e.xvals, e.yvals)} +/> +``` ## Examples diff --git a/src/__mocks__/plotly.js b/src/__mocks__/plotly.js index fe1ad31..5a77948 100644 --- a/src/__mocks__/plotly.js +++ b/src/__mocks__/plotly.js @@ -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; @@ -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'); diff --git a/src/__tests__/events.test.js b/src/__tests__/events.test.js new file mode 100644 index 0000000..794ce5a --- /dev/null +++ b/src/__tests__/events.test.js @@ -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); + }); +}); diff --git a/src/__tests__/react-plotly.test.js b/src/__tests__/react-plotly.test.js index bace500..db23fae 100644 --- a/src/__tests__/react-plotly.test.js +++ b/src/__tests__/react-plotly.test.js @@ -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('', () => { @@ -185,7 +186,7 @@ describe('', () => { }); }); - 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) => { @@ -203,6 +204,45 @@ describe('', () => { }) .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', () => { diff --git a/src/events.js b/src/events.js new file mode 100644 index 0000000..8f9410e --- /dev/null +++ b/src/events.js @@ -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)); diff --git a/src/factory.d.ts b/src/factory.d.ts index 34e75ea..91631a1 100644 --- a/src/factory.d.ts +++ b/src/factory.d.ts @@ -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; @@ -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; } @@ -78,8 +83,6 @@ export interface PlotParams { */ declare function createPlotlyComponent( Plotly: unknown -): React.ForwardRefExoticComponent< - PlotParams & React.RefAttributes ->; +): React.ForwardRefExoticComponent>; export default createPlotlyComponent; diff --git a/src/factory.js b/src/factory.js index 99c4860..823a4b9 100644 --- a/src/factory.js +++ b/src/factory.js @@ -1,53 +1,6 @@ import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; -// The naming convention is: -// - events are attached as `'plotly_' + eventName.toLowerCase()` -// - react props are `'on' + eventName` -const eventNames = [ - 'AfterExport', - 'AfterPlot', - 'Animated', - 'AnimatingFrame', - 'AnimationInterrupted', - 'AutoSize', - 'BeforeExport', - 'BeforeHover', - 'ButtonClicked', - 'Click', - 'ClickAnnotation', - 'ClickAnywhere', - 'Deselect', - 'DoubleClick', - 'Framework', - 'Hover', - 'HoverAnywhere', - 'LegendClick', - 'LegendDoubleClick', - 'Relayout', - 'Relayouting', - 'Restyle', - 'Redraw', - 'Selected', - 'Selecting', - 'SliderChange', - 'SliderEnd', - 'SliderStart', - 'SunburstClick', - 'Transitioning', - 'TransitionInterrupted', - 'Unhover', - 'WebGlContextLost', -]; - -const updateEvents = [ - 'plotly_restyle', - 'plotly_redraw', - 'plotly_relayout', - 'plotly_relayouting', - 'plotly_doubleclick', - 'plotly_animated', - 'plotly_sunburstclick', -]; +import {eventNames, getPlotlyEventName, getPropName, updateEvents} from './events.js'; // Check if a window is available since SSR (server-side rendering) // breaks unnecessarily if you try to use it server-side. @@ -59,10 +12,6 @@ const isBrowser = typeof window !== 'undefined'; const DEFAULT_DATA = Object.freeze([]); const DEFAULT_STYLE = Object.freeze({position: 'relative', display: 'inline-block'}); -function getPlotlyEventName(eventName) { - return 'plotly_' + eventName.toLowerCase(); -} - export default function plotComponentFactory(Plotly) { return forwardRef(function PlotlyComponent( { @@ -248,7 +197,7 @@ export default function plotComponentFactory(Plotly) { function syncEventHandlers() { eventNames.forEach((eventName) => { - const prop = eventProps['on' + eventName]; + const prop = eventProps[getPropName(eventName)]; const handler = handlersRef.current[eventName]; const hasHandler = Boolean(handler); if (prop && !hasHandler) {