diff --git a/src/components/legend/attributes.js b/src/components/legend/attributes.js index 0d7f0d49e9f..6a08a1bb31e 100644 --- a/src/components/legend/attributes.js +++ b/src/components/legend/attributes.js @@ -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', diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js index f8be07461f4..eb8a6212a0f 100644 --- a/src/components/legend/defaults.js +++ b/src/components/legend/defaults.js @@ -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); @@ -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); } } @@ -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; diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 49c793d3f12..c50ce2e58d4 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -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'); @@ -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; @@ -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) { @@ -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 { @@ -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) { @@ -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(), @@ -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) { @@ -504,7 +544,7 @@ 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); @@ -512,12 +552,14 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) { 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'); @@ -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); + } + }); +} + + 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() { @@ -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]; } @@ -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]; } @@ -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) { @@ -1009,7 +1137,3 @@ function getYanchor(legendObj) { Lib.isMiddleAnchor(legendObj) ? 'middle' : 'top'; } - -function getId(legendObj) { - return legendObj._id || 'legend'; -} diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js index 6c2b7b81b45..23c71bfbdaa 100644 --- a/src/components/legend/handle_click.js +++ b/src/components/legend/handle_click.js @@ -3,41 +3,44 @@ var Registry = require('../../registry'); var Lib = require('../../lib'); var pushUnique = Lib.pushUnique; +var helpers = require('./helpers'); var SHOWISOLATETIP = true; -module.exports = function handleClick(g, gd, numClicks) { +/** + * Handles click actions on individual legend items. + * + * @param {object} g D3 selection of the legend item element + * @param {object} gd graph div + * @param {object} legendObj the legend object from fullLayout + * @param {string} mode toggle mode for the current action: 'toggle' | 'toggleothers' + * - 'toggle': Toggle visibility of this item (or group if groupclick is 'togglegroup') + * - 'toggleothers': Show only this item, hide all others (isolation mode) + */ +exports.handleItemClick = function handleItemClick(g, gd, legendObj, mode) { 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; - if(numClicks === 1 && itemClick === 'toggle' && itemDoubleClick === 'toggleothers' && + 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 ) { Lib.notifier(Lib._(gd, 'Double-click on legend to isolate one trace'), 'long'); SHOWISOLATETIP = false; - } else { - SHOWISOLATETIP = false; } - var mode; - if(numClicks === 1) mode = itemClick; - else if(numClicks === 2) mode = itemDoubleClick; - if(!mode) return; - var toggleGroup = groupClick === 'togglegroup'; var hiddenSlices = fullLayout.hiddenlabels ? 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); @@ -269,3 +272,81 @@ module.exports = function handleClick(g, gd, numClicks) { } } }; + +/** + * Handles click actions on legend titles. + * + * @param {object} gd graph div (plot container) + * @param {object} legendObj the legend object from fullLayout + * @param {string} mode toggle mode for the current action: 'toggle' | 'toggleothers' + * - 'toggle': show/hide all items in this legend + * - 'toggleothers': isolate this legend (show its items, hide items in other legends) + */ +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); + + 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 + const anyVisibleHere = allLegendItems.some(function(item) { + return isInLegend(item) && item.visible === true; + }); + + toggleThisLegend = !anyVisibleHere; + } else { + // isolate this legend or set all legends to visible + const anyVisibleElsewhere = allLegendItems.some(function(item) { + return !isInLegend(item) && item.visible === true && item.showlegend !== false; + }); + + toggleThisLegend = true; + toggleOtherLegends = !anyVisibleElsewhere; + } + + const dataUpdate = { visible: [] }; + const dataIndices = []; + const updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; }); + var shapesUpdated = false; + + 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; + } + + 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) { + 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); + } +}; diff --git a/src/components/legend/helpers.js b/src/components/legend/helpers.js index 2e2c688db7f..38ad7353add 100644 --- a/src/components/legend/helpers.js +++ b/src/components/legend/helpers.js @@ -11,3 +11,7 @@ exports.isVertical = function isVertical(legendLayout) { exports.isReversed = function isReversed(legendLayout) { return (legendLayout.traceorder || '').indexOf('reversed') !== -1; }; + +exports.getId = function getId(legendObj) { + return legendObj._id || 'legend'; +}; diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 9fc21aabb47..907e1503923 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -2794,3 +2794,250 @@ describe('legend with custom legendwidth', function() { }).then(done, done.fail); }); }); + +describe('legend title click', function() { + "use strict"; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + afterEach(destroyGraphDiv); + + function clickTitle(legendId, clicks) { + return function() { + return new Promise(function(resolve) { + var selector = '.' + (legendId || 'legend') + 'titletoggle'; + var item = d3Select(selector).node(); + if(!item) { + fail('Could not find title toggle element: ' + selector); + return resolve(); + } + for(var i = 0; i < (clicks || 1); i++) { + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + } + setTimeout(resolve, DBLCLICKDELAY + 100); + }); + }; + } + + function extractVisibilities(data) { + return data.map(function(trace) { return trace.visible; }); + } + + function assertVisible(expectation) { + return function() { + var actual = extractVisibilities(gd._fullData); + expect(actual).toEqual(expectation); + }; + } + + function assertVisibleShapes(expectation) { + return function() { + var actual = extractVisibilities(gd._fullLayout.shapes); + expect(actual).toEqual(expectation); + }; + } + + describe('defaults', function() { + it('should disable title clicking by default for a single legend', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] } + ], { + legend: { title: { text: 'Legend' } } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe(false); + expect(gd._fullLayout.legend.titledoubleclick).toBe(false); + }).then(done, done.fail); + }); + + it('should enable title clicking by default for multiple legends', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' } } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe('toggle'); + expect(gd._fullLayout.legend.titledoubleclick).toBe('toggleothers'); + expect(gd._fullLayout.legend2.titleclick).toBe('toggle'); + expect(gd._fullLayout.legend2.titledoubleclick).toBe('toggleothers'); + }).then(done, done.fail); + }); + + it('should allow user to override titleclick and titledoubleclick', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] } + ], { + legend: { + title: { text: 'Legend' }, + titleclick: 'toggleothers', + titledoubleclick: 'toggle' + } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe('toggleothers'); + expect(gd._fullLayout.legend.titledoubleclick).toBe('toggle'); + }).then(done, done.fail); + }); + }); + + describe('toggle interactions', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(done); + }); + + it('should hide all traces in legend when clicking title (all visible)', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(clickTitle('legend')) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(done, done.fail); + }); + + it('should show all traces in legend when clicking title (all hidden)', function(done) { + Plotly.restyle(gd, 'visible', 'legendonly', [0, 1]) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(clickTitle('legend')) + .then(assertVisible([true, true, true, true])) + .then(done, done.fail); + }); + + it('should not affect traces with visible: false', function(done) { + Plotly.restyle(gd, 'visible', false, [0]) + .then(assertVisible([false, true, true, true])) + .then(clickTitle('legend')) + .then(assertVisible([false, 'legendonly', true, true])) + .then(done, done.fail); + }); + }); + + describe('toggleothers interactions', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(done); + }); + + it('should isolate this legend (hide others)', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(clickTitle('legend', 2)) + .then(assertVisible([true, true, 'legendonly', 'legendonly'])) + .then(done, done.fail); + }); + + it('should restore all when already isolated', function(done) { + Plotly.restyle(gd, 'visible', 'legendonly', [2, 3]) + .then(assertVisible([true, true, 'legendonly', 'legendonly'])) + .then(clickTitle('legend', 2)) + .then(assertVisible([true, true, true, true])) + .then(done, done.fail); + }); + }); + + describe('interactions with shapes', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 }, + shapes: [ + { showlegend: true, type: 'line', x0: 0, y0: 0, x1: 1, y1: 1 }, + { showlegend: true, type: 'rect', x0: 0, y0: 0, x1: 1, y1: 1, legend: 'legend2' } + ] + }).then(done); + }); + + it('should toggle shapes with traces', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(assertVisibleShapes([true, true])) + .then(clickTitle('legend')) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(assertVisibleShapes(['legendonly', true])) + .then(done, done.fail); + }); + }); + + it('should not create click target when no title text', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: {}, + legend2: { y: 0.5 } + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle'); + expect(titleToggle.size()).toBe(0); + }).then(done, done.fail); + }); + + it('should have a pointer cursor on hover for clickable titles', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle').node(); + expect(titleToggle.style.cursor).toBe('pointer'); + }).then(done, done.fail); + }); + + it('should not have pointer cursor on static plots', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }, { + staticPlot: true + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle').node(); + // On static plots, the title toggle rect is created but without pointer cursor + if(titleToggle) { + expect(titleToggle.style.cursor).not.toBe('pointer'); + } + }).then(done, done.fail); + }); +}); \ No newline at end of file diff --git a/test/plot-schema.json b/test/plot-schema.json index 211da680a56..e753455cb7f 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -3581,6 +3581,26 @@ "valType": "string" } }, + "titleclick": { + "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.", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "titledoubleclick": { + "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.", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, "tracegroupgap": { "description": "Sets the amount of vertical space (in px) between legend groups.", "dflt": 10,