Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
88a6c0d
Fix bug where `groupclick`: `toggleitem` doesn't work when there are …
alexshoe Jan 20, 2026
b2ef711
Add `titleclick` and `titledoubleclick` attributes
alexshoe Jan 20, 2026
cdb570a
Add handleTitleClick function for toggling visibility via legend title
alexshoe Jan 28, 2026
089fa73
Add legend title click toggle setup and event handling
alexshoe Jan 28, 2026
207910e
Add mock chart for legend title click feature
alexshoe Jan 28, 2026
2f934ad
Only enable legend title click by default when there are multiple leg…
alexshoe Jan 28, 2026
cd92fea
Add jasmine tests for legend title click
alexshoe Jan 29, 2026
53073d7
Update schema
alexshoe Jan 29, 2026
7c2e878
Merge remote-tracking branch 'origin/master' into clickable-legend-ti…
alexshoe Jan 29, 2026
459e229
Add baseline image
alexshoe Jan 29, 2026
6bec6d1
Convert var to const where applicable
alexshoe Jan 30, 2026
fdf1d65
Move `getId()` to `helpers.js`
alexshoe Feb 5, 2026
0cde808
Refactor handleClick() signature to be consistent with handleTitleClick
alexshoe Feb 5, 2026
3a1336f
Add docstrings for `handleClick` and `handleTitleClick`
alexshoe Feb 5, 2026
42c40f6
Replace null value with early continue and skip non-displayed traces
alexshoe Feb 5, 2026
2b5d2af
Rename `handleClick` to `handleItemClick`
alexshoe Feb 5, 2026
6cbdb49
Test legend title click attributes with non-default values
alexshoe Feb 5, 2026
6f6fd91
Move titleToggle positioning into computeLegendDimensions
alexshoe Feb 6, 2026
e3a068e
Fix group title click resolving to wrong legend by adding missing leg…
alexshoe Feb 12, 2026
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
26 changes: 26 additions & 0 deletions src/components/legend/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,32 @@ module.exports = {
'*togglegroup* toggles the visibility of all items in the same legendgroup as the item clicked on the graph.'
].join(' ')
},
titleclick: {
valType: 'enumerated',
values: ['toggle', 'toggleothers', false],
editType: 'legend',
description: [
'Determines the behavior on legend title click.',
'*toggle* toggles the visibility of all items in the legend.',
'*toggleothers* toggles the visibility of all other legends.',
'*false* disables legend title click interactions.',
'Defaults to *toggle* when there are multiple legends, *false* otherwise.',
'Does not work for legends containing pie and pie-like traces.'
].join(' ')
},
titledoubleclick: {
valType: 'enumerated',
values: ['toggle', 'toggleothers', false],
editType: 'legend',
description: [
'Determines the behavior on legend title double-click.',
'*toggle* toggles the visibility of all items in the legend.',
'*toggleothers* toggles the visibility of all other legends.',
'*false* disables legend title double-click interactions.',
'Defaults to *toggleothers* when there are multiple legends, *false* otherwise.',
'Does not currently work for legends containing pie and pie-like traces.'
].join(' ')
},
x: {
valType: 'number',
editType: 'legend',
Expand Down
8 changes: 6 additions & 2 deletions src/components/legend/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ var attributes = require('./attributes');
var basePlotLayoutAttributes = require('../../plots/layout_attributes');
var helpers = require('./helpers');

function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
function groupDefaults(legendId, layoutIn, layoutOut, fullData, legendCount) {
var containerIn = layoutIn[legendId] || {};
var containerOut = Template.newContainer(layoutOut, legendId);

Expand Down Expand Up @@ -238,6 +238,10 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
});

Lib.coerceFont(coerce, 'title.font', dfltTitleFont);

const hasMultipleLegends = legendCount > 1;
coerce('titleclick', hasMultipleLegends ? 'toggle' : false);
coerce('titledoubleclick', hasMultipleLegends ? 'toggleothers' : false);
}
}

Expand Down Expand Up @@ -277,7 +281,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
for(i = 0; i < legends.length; i++) {
var legendId = legends[i];

groupDefaults(legendId, layoutIn, layoutOut, allLegendsData);
groupDefaults(legendId, layoutIn, layoutOut, allLegendsData, legends.length);

if(layoutOut[legendId]) {
layoutOut[legendId]._id = legendId;
Expand Down
160 changes: 142 additions & 18 deletions src/components/legend/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ var dragElement = require('../dragelement');
var Drawing = require('../drawing');
var Color = require('../color');
var svgTextUtils = require('../../lib/svg_text_utils');
var handleClick = require('./handle_click');
var handleItemClick = require('./handle_click').handleItemClick;
var handleTitleClick = require('./handle_click').handleTitleClick;

var constants = require('./constants');
var alignmentConstants = require('../../constants/alignment');
Expand Down Expand Up @@ -82,7 +83,7 @@ function drawOne(gd, opts) {
var legendObj = opts || {};

var fullLayout = gd._fullLayout;
var legendId = getId(legendObj);
var legendId = helpers.getId(legendObj);

var clipId, layer;

Expand Down Expand Up @@ -180,8 +181,14 @@ function drawOne(gd, opts) {
.text(title.text);

textLayout(titleEl, scrollBox, gd, legendObj, MAIN_TITLE); // handle mathjax or multi-line text and compute title height

// Set up title click if enabled and not in hover mode
if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) {
setupTitleToggle(scrollBox, gd, legendObj, legendId);
}
} else {
scrollBox.selectAll('.' + legendId + 'titletext').remove();
scrollBox.selectAll('.' + legendId + 'titletoggle').remove();
}

var scrollBar = Lib.ensureSingle(legend, 'rect', 'scrollbar', function(s) {
Expand All @@ -198,7 +205,22 @@ function drawOne(gd, opts) {
traces.exit().remove();

traces.style('opacity', function(d) {
var trace = d[0].trace;
const legendItem = d[0];
const trace = legendItem.trace;

// Toggle opacity of legend group titles if all items in the group are hidden
if(legendItem.groupTitle) {
const groupName = trace.legendgroup;
const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; });
const anyVisible = gd._fullData.concat(shapes).some(function(item) {
return item.legendgroup === groupName &&
(item.legend || 'legend') === legendId &&
item.visible === true;
});

return anyVisible ? 1 : 0.5;
}

if(Registry.traceIs(trace, 'pie-like')) {
return hiddenSlices.indexOf(d[0].label) !== -1 ? 0.5 : 1;
} else {
Expand All @@ -207,20 +229,31 @@ function drawOne(gd, opts) {
})
.each(function() { d3.select(this).call(drawTexts, gd, legendObj); })
.call(style, gd, legendObj)
.each(function() { if(!inHover) d3.select(this).call(setupTraceToggle, gd, legendId); });
.each(function(d) {
if(inHover) return;
// Don't create a click targets for group titles when groupclick is 'toggleitem'
if(d[0].groupTitle && legendObj.groupclick === 'toggleitem') return;
d3.select(this).call(setupTraceToggle, gd, legendId);
});

Lib.syncOrAsync([
Plots.previousPromises,
function() { return computeLegendDimensions(gd, groups, traces, legendObj); },
function() { return computeLegendDimensions(gd, groups, traces, legendObj, scrollBox); },
function() {
var gs = fullLayout._size;
var bw = legendObj.borderwidth;
var isPaperX = legendObj.xref === 'paper';
var isPaperY = legendObj.yref === 'paper';

// re-calculate title position after legend width is derived. To allow for horizontal alignment
if(title.text) {
horizontalAlignTitle(titleEl, legendObj, bw);
// Toggle opacity of legend titles if all items in the legend are hidden
const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; });
const anyVisible = gd._fullData.concat(shapes).some(function(item) {
const inThisLegend = (item.legend || 'legend') === legendId;
return inThisLegend && item.visible === true;
});

titleEl.style('opacity', anyVisible ? 1 : 0.5);
}

if(!inHover) {
Expand Down Expand Up @@ -479,7 +512,14 @@ function getTraceWidth(d, legendObj, textGap) {
}

function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
var fullLayout = gd._fullLayout;
var trace = legendItem.data()[0][0].trace;
var legendId = trace.legend || 'legend';
var legendObj = fullLayout[legendId];

var itemClick = legendObj.itemclick;
var itemDoubleClick = legendObj.itemdoubleclick;

var evtData = {
event: evt,
node: legendItem.node(),
Expand All @@ -490,7 +530,7 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
frames: gd._transitionData._frames,
config: gd._context,
fullData: gd._fullData,
fullLayout: gd._fullLayout
fullLayout: fullLayout
};

if(trace._group) {
Expand All @@ -504,20 +544,22 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
if(clickVal === false) return;
legend._clickTimeout = setTimeout(function() {
if(!gd._fullLayout) return;
handleClick(legendItem, gd, numClicks);
if(itemClick) handleItemClick(legendItem, gd, legendObj, itemClick);
}, gd._context.doubleClickDelay);
} else if(numClicks === 2) {
if(legend._clickTimeout) clearTimeout(legend._clickTimeout);
gd._legendMouseDownTime = 0;

var dblClickVal = Events.triggerHandler(gd, 'plotly_legenddoubleclick', evtData);
// Activate default double click behaviour only when both single click and double click values are not false
if(dblClickVal !== false && clickVal !== false) handleClick(legendItem, gd, numClicks);
if(dblClickVal !== false && clickVal !== false && itemDoubleClick) {
handleItemClick(legendItem, gd, legendObj, itemDoubleClick);
}
}
}

function drawTexts(g, gd, legendObj) {
var legendId = getId(legendObj);
var legendId = helpers.getId(legendObj);
var legendItem = g.data()[0][0];
var trace = legendItem.trace;
var isPieLike = Registry.traceIs(trace, 'pie-like');
Expand Down Expand Up @@ -624,6 +666,73 @@ function setupTraceToggle(g, gd, legendId) {
});
}

function setupTitleToggle(scrollBox, gd, legendObj, legendId) {
// For now, skip title click for legends containing pie-like traces
const hasPie = gd._fullData.some(function(trace) {
const legend = trace.legend || 'legend';
const inThisLegend = Array.isArray(legend) ? legend.includes(legendId) : legend === legendId;
return inThisLegend && Registry.traceIs(trace, 'pie-like');
});
if(hasPie) return;

const doubleClickDelay = gd._context.doubleClickDelay;
var newMouseDownTime;
var numClicks = 1;

const titleToggle = Lib.ensureSingle(scrollBox, 'rect', legendId + 'titletoggle', function(s) {
if(!gd._context.staticPlot) {
s.style('cursor', 'pointer').attr('pointer-events', 'all');
}
s.call(Color.fill, 'rgba(0,0,0,0)');
});

if(gd._context.staticPlot) return;

titleToggle.on('mousedown', function() {
newMouseDownTime = (new Date()).getTime();
if(newMouseDownTime - gd._legendMouseDownTime < doubleClickDelay) {
// in a click train
numClicks += 1;
} else {
// new click train
numClicks = 1;
gd._legendMouseDownTime = newMouseDownTime;
}
});
titleToggle.on('mouseup', function() {
if(gd._dragged || gd._editing) return;

if((new Date()).getTime() - gd._legendMouseDownTime > doubleClickDelay) {
numClicks = Math.max(numClicks - 1, 1);
}

const evtData = {
event: d3.event,
legendId: legendId,
data: gd.data,
layout: gd.layout,
fullData: gd._fullData,
fullLayout: gd._fullLayout
};

if(numClicks === 1 && legendObj.titleclick) {
const clickVal = Events.triggerHandler(gd, 'plotly_legendtitleclick', evtData);
if(clickVal === false) return;

legendObj._titleClickTimeout = setTimeout(function() {
if(gd._fullLayout) handleTitleClick(gd, legendObj, legendObj.titleclick);
}, doubleClickDelay);
} else if(numClicks === 2) {
if(legendObj._titleClickTimeout) clearTimeout(legendObj._titleClickTimeout);
gd._legendMouseDownTime = 0;

const dblClickVal = Events.triggerHandler(gd, 'plotly_legendtitledoubleclick', evtData);
if(dblClickVal !== false && legendObj.titledoubleclick) handleTitleClick(gd, legendObj, legendObj.titledoubleclick);
}
});
Comment on lines +718 to +732
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexshoe Could some of the logic in the clickOrDoubleClick() function possibly be reused here? Seems like a fair amount of code duplication, although I'm sure there are some subtle differences.

}


function textLayout(s, g, gd, legendObj, aTitle) {
if(legendObj._inHover) s.attr('data-notex', true); // do not process MathJax for unified hover
svgTextUtils.convertToTspans(s, gd, function() {
Expand All @@ -645,7 +754,7 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {
var mathjaxGroup = g.select('g[class*=math-group]');
var mathjaxNode = mathjaxGroup.node();

var legendId = getId(legendObj);
var legendId = helpers.getId(legendObj);
if(!legendObj) {
legendObj = gd._fullLayout[legendId];
}
Expand Down Expand Up @@ -748,9 +857,9 @@ function getTitleSize(legendObj) {
* - _width: legend width
* - _maxWidth (for orientation:h only): maximum width before starting new row
*/
function computeLegendDimensions(gd, groups, traces, legendObj) {
function computeLegendDimensions(gd, groups, traces, legendObj, scrollBox) {
var fullLayout = gd._fullLayout;
var legendId = getId(legendObj);
var legendId = helpers.getId(legendObj);
if(!legendObj) {
legendObj = fullLayout[legendId];
}
Expand Down Expand Up @@ -955,6 +1064,25 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
}
Drawing.setRect(traceToggle, 0, -h / 2, w, h);
});

// align legend title horizontally
var titleEl = scrollBox.select('.' + legendId + 'titletext');
if(titleEl.node()) {
horizontalAlignTitle(titleEl, legendObj, bw);
}

// position title click target to cover the title text, parallel to traceToggle above
var titleToggle = scrollBox.select('.' + legendId + 'titletoggle');
if(titleToggle.size() && titleEl.node()) {
var titleX = titleEl.attr('x') || 0;
var pad = constants.titlePad;
Drawing.setRect(titleToggle,
titleX - pad,
bw,
legendObj._titleWidth + 2 * pad,
legendObj._titleHeight + 2 * pad
);
}
}

function expandMargin(gd, legendId, lx, ly) {
Expand Down Expand Up @@ -1009,7 +1137,3 @@ function getYanchor(legendObj) {
Lib.isMiddleAnchor(legendObj) ? 'middle' :
'top';
}

function getId(legendObj) {
return legendObj._id || 'legend';
}
1 change: 1 addition & 0 deletions src/components/legend/get_legend_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ module.exports = function getLegendData(calcdata, opts, hasMultipleLegends) {
trace: {
showlegend: firstItemTrace.showlegend,
legendgroup: firstItemTrace.legendgroup,
legend: firstItemTrace.legend,
visible: opts.groupclick === 'toggleitem' ? true : firstItemTrace.visible
}
});
Expand Down
Loading