Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 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
28990fb
Update schema
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
24 changes: 24 additions & 0 deletions src/components/legend/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ 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.'
].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.'
].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);

var 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
133 changes: 130 additions & 3 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 handleClick = require('./handle_click').handleClick;
var handleTitleClick = require('./handle_click').handleTitleClick;

var constants = require('./constants');
var alignmentConstants = require('../../constants/alignment');
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;
var legendItem = d[0];
var trace = legendItem.trace;

// Toggle opacity of legend group titles if all items in the group are hidden
if(legendItem.groupTitle) {
var groupName = trace.legendgroup;
var shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; });
var 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,7 +229,12 @@ 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,
Expand All @@ -221,6 +248,20 @@ function drawOne(gd, opts) {
// re-calculate title position after legend width is derived. To allow for horizontal alignment
if(title.text) {
horizontalAlignTitle(titleEl, legendObj, bw);

// Position click target for the title after dimensions are computed
if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) {
positionTitleToggle(scrollBox, legendObj, legendId);
}

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

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

if(!inHover) {
Expand Down Expand Up @@ -624,6 +665,92 @@ function setupTraceToggle(g, gd, legendId) {
});
}

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

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

var 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);
}

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

if(numClicks === 1 && legendObj.titleclick) {
var 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;

var dblClickVal = Events.triggerHandler(gd, 'plotly_legendtitledoubleclick', evtData);
if(dblClickVal !== false && legendObj.titledoubleclick) handleTitleClick(gd, legendObj, legendObj.titledoubleclick);
}
});
}

function positionTitleToggle(scrollBox, legendObj, legendId) {
var titleToggle = scrollBox.select('.' + legendId + 'titletoggle');
if(!titleToggle.size()) return;

var side = legendObj.title.side || 'top';
var bw = legendObj.borderwidth;
var x = bw;
var width = legendObj._titleWidth + 2 * constants.titlePad;
var height = legendObj._titleHeight + 2 * constants.titlePad;


if(side === 'top center') {
x = bw + 0.5 * (legendObj._width - 2 * bw - width);
} else if(side === 'top right') {
x = legendObj._width - bw - width;
}

titleToggle.attr({ x: x, y: bw, width: width, height: height });
}

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 Down
79 changes: 72 additions & 7 deletions src/components/legend/handle_click.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ var pushUnique = Lib.pushUnique;

var SHOWISOLATETIP = true;

module.exports = function handleClick(g, gd, numClicks) {
exports.handleClick = function handleClick(g, gd, numClicks) {
var fullLayout = gd._fullLayout;

if(gd._dragged || gd._editing) return;

var itemClick = fullLayout.legend.itemclick;
var itemDoubleClick = fullLayout.legend.itemdoubleclick;
var groupClick = fullLayout.legend.groupclick;

var legendItem = g.data()[0][0];
if(legendItem.groupTitle && legendItem.noClick) return;

var legendId = legendItem.trace.legend || 'legend';
var legendObj = fullLayout[legendId];

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

if(numClicks === 1 && itemClick === 'toggle' && itemDoubleClick === 'toggleothers' &&
SHOWISOLATETIP && gd.data && gd._context.showTips
Expand All @@ -35,9 +42,6 @@ module.exports = function handleClick(g, gd, numClicks) {
fullLayout.hiddenlabels.slice() :
[];

var legendItem = g.data()[0][0];
if(legendItem.groupTitle && legendItem.noClick) return;

var fullData = gd._fullData;
var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; });
var allLegendItems = fullData.concat(shapesWithLegend);
Expand Down Expand Up @@ -269,3 +273,64 @@ module.exports = function handleClick(g, gd, numClicks) {
}
}
};

exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Also similar comment here to above -- check whether you can share some of this logic with the handleClick function to avoid TOO much code duplication

var fullLayout = gd._fullLayout;
var fullData = gd._fullData;
var legendId = legendObj._id || 'legend';
var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; });
var allLegendItems = fullData.concat(shapesWithLegend);

function isInLegend(item) {
return (item.legend || 'legend') === legendId;
}

var toggleThisLegend;
var toggleOtherLegends;

if(mode === 'toggle') {
// If any item is visible in this legend, hide all. If all are hidden, show all
var anyVisibleHere = allLegendItems.some(function(item) {
return isInLegend(item) && item.visible === true;
});

toggleThisLegend = !anyVisibleHere;
toggleOtherLegends = null;
} else {
// isolate this legend or set all legends to visible
var anyVisibleElsewhere = allLegendItems.some(function(item) {
return !isInLegend(item) && item.visible === true && item.showlegend !== false;
});

toggleThisLegend = true;
toggleOtherLegends = !anyVisibleElsewhere;
}

var dataUpdate = { visible: [] };
var dataIndices = [];
var updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; });
var shapesUpdated = false;

for(var i = 0; i < allLegendItems.length; i++) {
var item = allLegendItems[i];
var shouldShow = isInLegend(item) ? toggleThisLegend : toggleOtherLegends;
var newVis = shouldShow ? true : 'legendonly';

// Only update if the item is visible and the visibility is different from the new visibility
if ((item.visible !== false) && (shouldShow !== null) && (item.visible !== newVis)) {
if(item._isShape) {
updatedShapes[item._index].visible = newVis;
shapesUpdated = true;
} else {
dataIndices.push(item.index);
dataUpdate.visible.push(newVis);
}
}
}

if(shapesUpdated) {
Registry.call('_guiUpdate', gd, dataUpdate, {shapes: updatedShapes}, dataIndices);
} else if(dataIndices.length) {
Registry.call('_guiRestyle', gd, dataUpdate, dataIndices);
}
};
Binary file added test/image/baselines/legend_title_click.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading