From 5b9ab5d00f6f891615fa5659247cc77b33b6d932 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 28 May 2026 16:54:03 -0600 Subject: [PATCH 1/5] Don't gate legend/legendgroup coercion --- src/components/shapes/defaults.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js index c4a4d1e47e0..48b972a85af 100644 --- a/src/components/shapes/defaults.js +++ b/src/components/shapes/defaults.js @@ -38,10 +38,11 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) { if (!visible) return; var showlegend = coerce('showlegend'); + // Coerce legend/legendgroup even when showlegend is false so hidden group members still toggle with the group. + coerce('legend'); + coerce('legendgroup'); if (showlegend) { - coerce('legend'); coerce('legendwidth'); - coerce('legendgroup'); coerce('legendgrouptitle.text'); Lib.coerceFont(coerce, 'legendgrouptitle.font'); coerce('legendrank'); From 61b81860b4ee1ac1c5c2361ff2477b69fcd50eb3 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 28 May 2026 16:54:42 -0600 Subject: [PATCH 2/5] Linting/formatting --- src/components/legend/handle_click.js | 153 ++++++++++++++------------ 1 file changed, 83 insertions(+), 70 deletions(-) diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js index 0d0886bcb23..c713af4cc44 100644 --- a/src/components/legend/handle_click.js +++ b/src/components/legend/handle_click.js @@ -20,16 +20,20 @@ var SHOWISOLATETIP = true; exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { var fullLayout = gd._fullLayout; - if(gd._dragged || gd._editing) return; + if (gd._dragged || gd._editing) return; var legendItem = g.data()[0][0]; - if(legendItem.groupTitle && legendItem.noClick) return; + if (legendItem.groupTitle && legendItem.noClick) return; var groupClick = legendObj.groupclick; // Show isolate tip on first single click when default behavior is active - if(mode === 'toggle' && legendObj.itemdoubleclick === 'toggleothers' && - SHOWISOLATETIP && gd.data && gd._context.showTips + if ( + mode === 'toggle' && + legendObj.itemdoubleclick === 'toggleothers' && + SHOWISOLATETIP && + gd.data && + gd._context.showTips ) { Lib.notifier(Lib._(gd, 'Double-click on legend to isolate one trace'), 'long', gd); SHOWISOLATETIP = false; @@ -37,16 +41,16 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { var toggleGroup = groupClick === 'togglegroup'; - var hiddenSlices = fullLayout.hiddenlabels ? - fullLayout.hiddenlabels.slice() : - []; + var hiddenSlices = fullLayout.hiddenlabels ? fullLayout.hiddenlabels.slice() : []; var fullData = gd._fullData; - var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); + var shapesWithLegend = (fullLayout.shapes || []).filter(function (d) { + return d.showlegend; + }); var allLegendItems = fullData.concat(shapesWithLegend); var fullTrace = legendItem.trace; - if(fullTrace._isShape) { + if (fullTrace._isShape) { fullTrace = fullTrace._fullInput; } @@ -61,11 +65,11 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { function insertDataUpdate(traceIndex, value) { var attrIndex = dataIndices.indexOf(traceIndex); var valueArray = dataUpdate.visible; - if(!valueArray) { + if (!valueArray) { valueArray = dataUpdate.visible = []; } - if(dataIndices.indexOf(traceIndex) === -1) { + if (dataIndices.indexOf(traceIndex) === -1) { dataIndices.push(traceIndex); attrIndex = dataIndices.length - 1; } @@ -75,7 +79,7 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { return attrIndex; } - var updatedShapes = (fullLayout.shapes || []).map(function(d) { + var updatedShapes = (fullLayout.shapes || []).map(function (d) { return d._input; }); @@ -87,19 +91,19 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { } function setVisibility(fullTrace, visibility) { - if(legendItem.groupTitle && !toggleGroup) return; + if (legendItem.groupTitle && !toggleGroup) return; var fullInput = fullTrace._fullInput || fullTrace; var isShape = fullInput._isShape; var index = fullInput.index; - if(index === undefined) index = fullInput._index; + if (index === undefined) index = fullInput._index; // false -> false (not possible since will not be visible in legend) // true -> legendonly // legendonly -> true var nextVisibility = fullInput.visible === false ? false : visibility; - if(isShape) { + if (isShape) { insertShapesUpdate(index, nextVisibility); } else { insertDataUpdate(index, nextVisibility); @@ -111,26 +115,26 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { var fullInput = fullTrace._fullInput; var isShape = fullInput && fullInput._isShape; - if(!isShape && Registry.traceIs(fullTrace, 'pie-like')) { + if (!isShape && Registry.traceIs(fullTrace, 'pie-like')) { var thisLabel = legendItem.label; var thisLabelIndex = hiddenSlices.indexOf(thisLabel); - if(mode === 'toggle') { - if(thisLabelIndex === -1) hiddenSlices.push(thisLabel); + if (mode === 'toggle') { + if (thisLabelIndex === -1) hiddenSlices.push(thisLabel); else hiddenSlices.splice(thisLabelIndex, 1); - } else if(mode === 'toggleothers') { + } else if (mode === 'toggleothers') { var changed = thisLabelIndex !== -1; var unhideList = []; - for(i = 0; i < gd.calcdata.length; i++) { + for (i = 0; i < gd.calcdata.length; i++) { var cdi = gd.calcdata[i]; - for(j = 0; j < cdi.length; j++) { + for (j = 0; j < cdi.length; j++) { var d = cdi[j]; var dLabel = d.label; // ensure we toggle slices that are in this legend) - if(thisLegend === cdi[0].trace.legend) { - if(thisLabel !== dLabel) { - if(hiddenSlices.indexOf(dLabel) === -1) changed = true; + if (thisLegend === cdi[0].trace.legend) { + if (thisLabel !== dLabel) { + if (hiddenSlices.indexOf(dLabel) === -1) changed = true; pushUnique(hiddenSlices, dLabel); unhideList.push(dLabel); } @@ -138,10 +142,10 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { } } - if(!changed) { - for(var q = 0; q < unhideList.length; q++) { + if (!changed) { + for (var q = 0; q < unhideList.length; q++) { var pos = hiddenSlices.indexOf(unhideList[q]); - if(pos !== -1) { + if (pos !== -1) { hiddenSlices.splice(pos, 1); } } @@ -153,20 +157,20 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { var hasLegendgroup = legendgroup && legendgroup.length; var traceIndicesInGroup = []; var tracei; - if(hasLegendgroup) { - for(i = 0; i < allLegendItems.length; i++) { + if (hasLegendgroup) { + for (i = 0; i < allLegendItems.length; i++) { tracei = allLegendItems[i]; - if(!tracei.visible) continue; - if(tracei.legendgroup === legendgroup) { + if (!tracei.visible) continue; + if (tracei.legendgroup === legendgroup) { traceIndicesInGroup.push(i); } } } - if(mode === 'toggle') { + if (mode === 'toggle') { var nextVisibility; - switch(fullTrace.visible) { + switch (fullTrace.visible) { case true: nextVisibility = 'legendonly'; break; @@ -178,11 +182,11 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { break; } - if(hasLegendgroup) { - if(toggleGroup) { - for(i = 0; i < allLegendItems.length; i++) { + if (hasLegendgroup) { + if (toggleGroup) { + for (i = 0; i < allLegendItems.length; i++) { var item = allLegendItems[i]; - if(item.visible !== false && item.legendgroup === legendgroup) { + if (item.visible !== false && item.legendgroup === legendgroup) { setVisibility(item, nextVisibility); } } @@ -192,36 +196,41 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { } else { setVisibility(fullTrace, nextVisibility); } - } else if(mode === 'toggleothers') { + } else if (mode === 'toggleothers') { // Compute the clicked index. expandedIndex does what we want for expanded traces // but also culls hidden traces. That means we have some work to do. var isClicked, isInGroup, notInLegend, otherState, _item; var isIsolated = true; - for(i = 0; i < allLegendItems.length; i++) { + for (i = 0; i < allLegendItems.length; i++) { _item = allLegendItems[i]; isClicked = _item === fullTrace; notInLegend = _item.showlegend !== true; - if(isClicked || notInLegend) continue; + if (isClicked || notInLegend) continue; - isInGroup = (hasLegendgroup && _item.legendgroup === legendgroup); + isInGroup = hasLegendgroup && _item.legendgroup === legendgroup; - if(!isInGroup && _item.legend === thisLegend && _item.visible === true && !Registry.traceIs(_item, 'notLegendIsolatable')) { + if ( + !isInGroup && + _item.legend === thisLegend && + _item.visible === true && + !Registry.traceIs(_item, 'notLegendIsolatable') + ) { isIsolated = false; break; } } - for(i = 0; i < allLegendItems.length; i++) { + for (i = 0; i < allLegendItems.length; i++) { _item = allLegendItems[i]; // False is sticky; we don't change it. Also ensure we don't change states of itmes in other legend - if(_item.visible === false || _item.legend !== thisLegend) continue; + if (_item.visible === false || _item.legend !== thisLegend) continue; - if(Registry.traceIs(_item, 'notLegendIsolatable')) { + if (Registry.traceIs(_item, 'notLegendIsolatable')) { continue; } - switch(fullTrace.visible) { + switch (fullTrace.visible) { case 'legendonly': setVisibility(_item, true); break; @@ -229,21 +238,21 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { otherState = isIsolated ? true : 'legendonly'; isClicked = _item === fullTrace; // N.B. consider traces that have a set legendgroup as toggleable - notInLegend = (_item.showlegend !== true && !_item.legendgroup); + notInLegend = _item.showlegend !== true && !_item.legendgroup; isInGroup = isClicked || (hasLegendgroup && _item.legendgroup === legendgroup); - setVisibility(_item, (isInGroup || notInLegend) ? true : otherState); + setVisibility(_item, isInGroup || notInLegend ? true : otherState); break; } } } - for(i = 0; i < carrs.length; i++) { + for (i = 0; i < carrs.length; i++) { kcont = carrs[i]; - if(!kcont) continue; + if (!kcont) continue; var update = kcont.constructUpdate(); var updateKeys = Object.keys(update); - for(j = 0; j < updateKeys.length; j++) { + for (j = 0; j < updateKeys.length; j++) { key = updateKeys[j]; val = dataUpdate[key] = dataUpdate[key] || []; val[carrIdx[i]] = update[key]; @@ -255,18 +264,18 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { // as updates and not accidentally reset to the default value. This fills // out sparse arrays with the required number of undefined values: keys = Object.keys(dataUpdate); - for(i = 0; i < keys.length; i++) { + for (i = 0; i < keys.length; i++) { key = keys[i]; - for(j = 0; j < dataIndices.length; j++) { + for (j = 0; j < dataIndices.length; j++) { // Use hasOwnProperty to protect against falsy values: - if(!dataUpdate[key].hasOwnProperty(j)) { + if (!dataUpdate[key].hasOwnProperty(j)) { dataUpdate[key][j] = undefined; } } } - if(shapesUpdated) { - Registry.call('_guiUpdate', gd, dataUpdate, {shapes: updatedShapes}, dataIndices); + if (shapesUpdated) { + Registry.call('_guiUpdate', gd, dataUpdate, { shapes: updatedShapes }, dataIndices); } else { Registry.call('_guiRestyle', gd, dataUpdate, dataIndices); } @@ -286,7 +295,9 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { const fullLayout = gd._fullLayout; const fullData = gd._fullData; const legendId = helpers.getId(legendObj); - const shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); + const shapesWithLegend = (fullLayout.shapes || []).filter(function (d) { + return d.showlegend; + }); const allLegendItems = fullData.concat(shapesWithLegend); function isInLegend(item) { @@ -296,9 +307,9 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { var toggleThisLegend; var toggleOtherLegends; - if(mode === 'toggle') { + if (mode === 'toggle') { // If any item is visible in this legend, hide all. If all are hidden, show all - const anyVisibleHere = allLegendItems.some(function(item) { + const anyVisibleHere = allLegendItems.some(function (item) { return isInLegend(item) && item.visible === true; }); @@ -306,7 +317,7 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { toggleOtherLegends = false; } else { // isolate this legend or set all legends to visible - const anyVisibleElsewhere = allLegendItems.some(function(item) { + const anyVisibleElsewhere = allLegendItems.some(function (item) { return !isInLegend(item) && item.visible === true && item.showlegend !== false; }); @@ -316,26 +327,28 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { const dataUpdate = { visible: [] }; const dataIndices = []; - const updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; }); + const updatedShapes = (fullLayout.shapes || []).map(function (d) { + return d._input; + }); var shapesUpdated = false; - for(var i = 0; i < allLegendItems.length; i++) { + for (var i = 0; i < allLegendItems.length; i++) { const item = allLegendItems[i]; const inThisLegend = isInLegend(item); // If item is not in this legend, skip if in toggle mode // or if item is not displayed in the legend - if(!inThisLegend) { - const notDisplayed = (item.showlegend !== true && !item.legendgroup); - if(mode === 'toggle' || notDisplayed) continue; + if (!inThisLegend) { + const notDisplayed = item.showlegend !== true && !item.legendgroup; + if (mode === 'toggle' || notDisplayed) continue; } const shouldShow = inThisLegend ? toggleThisLegend : toggleOtherLegends; const newVis = shouldShow ? true : 'legendonly'; // Only update if visibility would actually change - if((item.visible !== false) && (item.visible !== newVis)) { - if(item._isShape) { + if (item.visible !== false && item.visible !== newVis) { + if (item._isShape) { updatedShapes[item._index].visible = newVis; shapesUpdated = true; } else { @@ -345,9 +358,9 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { } } - if(shapesUpdated) { - Registry.call('_guiUpdate', gd, dataUpdate, {shapes: updatedShapes}, dataIndices); - } else if(dataIndices.length) { + if (shapesUpdated) { + Registry.call('_guiUpdate', gd, dataUpdate, { shapes: updatedShapes }, dataIndices); + } else if (dataIndices.length) { Registry.call('_guiRestyle', gd, dataUpdate, dataIndices); } }; From 1a08754e6596df00030e9723cabc705d300056ac Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 28 May 2026 16:56:00 -0600 Subject: [PATCH 3/5] Include shapes with legendgroup when handling legend click --- src/components/legend/handle_click.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js index c713af4cc44..be8d7c533b3 100644 --- a/src/components/legend/handle_click.js +++ b/src/components/legend/handle_click.js @@ -44,10 +44,9 @@ exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { var hiddenSlices = fullLayout.hiddenlabels ? fullLayout.hiddenlabels.slice() : []; var fullData = gd._fullData; - var shapesWithLegend = (fullLayout.shapes || []).filter(function (d) { - return d.showlegend; - }); - var allLegendItems = fullData.concat(shapesWithLegend); + // legendgroup membership matters even when showlegend is false, so togglegroup reaches hidden group peers. + const shapesInLegend = (fullLayout.shapes || []).filter((d) => d.showlegend || d.legendgroup); + var allLegendItems = fullData.concat(shapesInLegend); var fullTrace = legendItem.trace; if (fullTrace._isShape) { @@ -295,10 +294,8 @@ exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { const fullLayout = gd._fullLayout; const fullData = gd._fullData; const legendId = helpers.getId(legendObj); - const shapesWithLegend = (fullLayout.shapes || []).filter(function (d) { - return d.showlegend; - }); - const allLegendItems = fullData.concat(shapesWithLegend); + const shapesInLegend = (fullLayout.shapes || []).filter((d) => d.showlegend || d.legendgroup); + const allLegendItems = fullData.concat(shapesInLegend); function isInLegend(item) { return (item.legend || 'legend') === legendId; From c1b147f0c134d4b2808d9c92b737027480052247 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 28 May 2026 16:56:28 -0600 Subject: [PATCH 4/5] Add test --- test/jasmine/tests/legend_test.js | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index b27bf0726af..c5b252da9b1 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -2346,6 +2346,46 @@ describe('legend interaction', function () { }); }); + describe('shape legendgroup with showlegend:false members', () => { + beforeEach(() => Plotly.newPlot(gd, [], { + shapes: [ + { + showlegend: true, + legendgroup: 'g', + type: 'line', + xref: 'paper', yref: 'paper', + x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 + }, + { + showlegend: false, + legendgroup: 'g', + type: 'line', + xref: 'paper', yref: 'paper', + x0: 0.3, y0: 0.3, x1: 0.4, y1: 0.4 + }, + { + showlegend: true, + type: 'line', + xref: 'paper', yref: 'paper', + x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 + } + ] + })); + + it('toggles all group members when clicking the visible group entry', async () => { + assertVisibleShapes([true, true, true])(); + await click(0)(); + assertVisibleShapes(['legendonly', 'legendonly', true])(); + await click(0)(); + assertVisibleShapes([true, true, true])(); + }); + + it('isolates the group and hides showlegend:false members of other groups', async () => { + await click(0, 2)(); + assertVisibleShapes([true, true, 'legendonly'])(); + }); + }); + describe('legendgroup visibility', function () { beforeEach(function (done) { Plotly.newPlot(gd, [ From 76296e8820a3ea2df49c1753c42aafc6a955a08e Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 29 May 2026 10:48:28 -0600 Subject: [PATCH 5/5] Add draftlog --- draftlogs/7813_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7813_fix.md diff --git a/draftlogs/7813_fix.md b/draftlogs/7813_fix.md new file mode 100644 index 00000000000..1a026c6beae --- /dev/null +++ b/draftlogs/7813_fix.md @@ -0,0 +1 @@ +- Include shapes with `legendgroup` specified when handling legend visibility toggling [[#7813](https://github.com/plotly/plotly.js/pull/7813)]