From 69ccbf188ce2349794665421d19417505dd3e49c Mon Sep 17 00:00:00 2001 From: Eddie Ho Date: Tue, 29 Sep 2026 09:06:16 -0700 Subject: [PATCH] feat(toolbox): support `select.iconStyle` for the icons toggled on. close #21672 --- src/component/toolbox/ToolboxModel.ts | 9 + src/component/toolbox/ToolboxView.ts | 24 +- src/component/toolbox/feature/Brush.ts | 13 +- src/component/toolbox/feature/DataZoom.ts | 3 +- src/component/toolbox/feature/MagicType.ts | 6 +- src/component/toolbox/featureManager.ts | 8 + test/toolbox-select-iconStyle.html | 242 ++++++++++++++ .../spec/component/toolbox/iconStatus.test.ts | 313 ++++++++++++++++++ 8 files changed, 604 insertions(+), 14 deletions(-) create mode 100644 test/toolbox-select-iconStyle.html create mode 100644 test/ut/spec/component/toolbox/iconStatus.test.ts diff --git a/src/component/toolbox/ToolboxModel.ts b/src/component/toolbox/ToolboxModel.ts index 50d876d687..553a4eb53c 100644 --- a/src/component/toolbox/ToolboxModel.ts +++ b/src/component/toolbox/ToolboxModel.ts @@ -75,6 +75,15 @@ export interface ToolboxOption extends iconStyle?: ItemStyleOption } + /** + * The style of the icons in `select` status, that is, the icons that are currently + * toggled on, such as the icon of the current type in `magicType`. + * If not specified, `emphasis.iconStyle` is used. + */ + select?: { + iconStyle?: ItemStyleOption + } + textStyle?: LabelOption tooltip?: CommonTooltipOption diff --git a/src/component/toolbox/ToolboxView.ts b/src/component/toolbox/ToolboxView.ts index 8b7c82421a..f7c635b3dd 100644 --- a/src/component/toolbox/ToolboxView.ts +++ b/src/component/toolbox/ToolboxView.ts @@ -19,7 +19,7 @@ import * as textContain from 'zrender/src/contain/text'; import * as graphic from '../../util/graphic'; -import { enterEmphasis, leaveEmphasis } from '../../util/states'; +import { enterEmphasis, leaveEmphasis, enterSelect, leaveSelect } from '../../util/states'; import Model from '../../model/Model'; import DataDiffer from '../../data/DataDiffer'; import * as listComponentHelper from '../helper/listComponent'; @@ -169,7 +169,7 @@ class ToolboxView extends ComponentView { option.iconStatus = option.iconStatus || {}; option.iconStatus[iconName] = status; if (iconPaths[iconName]) { - (status === 'emphasis' ? enterEmphasis : leaveEmphasis)(iconPaths[iconName]); + applyIconStatus(iconPaths[iconName], status); } }; @@ -185,6 +185,12 @@ class ToolboxView extends ComponentView { ) { const iconStyleModel = featureModel.getModel('iconStyle'); const iconStyleEmphasisModel = featureModel.getModel(['emphasis', 'iconStyle']); + // `select.iconStyle` is optional. If it is not specified on either the feature or the toolbox, + // use `emphasis.iconStyle`. That keeps the backward compatibility, since the icons toggled on + // were previously displayed in emphasis state. + const iconStyleSelectModel = featureModel.get(['select', 'iconStyle']) != null + ? featureModel.getModel(['select', 'iconStyle']) + : iconStyleEmphasisModel; // If one feature has multiple icons, they are organized as // { @@ -233,6 +239,9 @@ class ToolboxView extends ComponentView { const pathEmphasisState = path.ensureState('emphasis'); pathEmphasisState.style = iconStyleEmphasisModel.getItemStyle(); + const pathSelectState = path.ensureState('select'); + pathSelectState.style = iconStyleSelectModel.getItemStyle(); + // Text position calculation // TODO: extract `textStyle` from `iconStyle` and use `createTextStyle` const textContent = new ZRText({ @@ -297,7 +306,7 @@ class ToolboxView extends ComponentView { } textContent.hide(); }); - (featureModel.get(['iconStatus', iconName]) === 'emphasis' ? enterEmphasis : leaveEmphasis)(path); + applyIconStatus(path, featureModel.get(['iconStatus', iconName])); group.add(path); (path as graphic.Path).on('click', bind( @@ -396,6 +405,15 @@ class ToolboxView extends ComponentView { } +/** + * `'select'` represents that the icon is toggled on. `'emphasis'` is also supported as a status, + * which was used for that purpose previously and can still be specified by users. + */ +function applyIconStatus(iconPath: IconPath, status: DisplayState | NullUndefined): void { + (status === 'emphasis' ? enterEmphasis : leaveEmphasis)(iconPath); + (status === 'select' ? enterSelect : leaveSelect)(iconPath); +} + function isUserFeatureName(featureName: string): boolean { return featureName.indexOf('my') === 0; } diff --git a/src/component/toolbox/feature/Brush.ts b/src/component/toolbox/feature/Brush.ts index 7c4b7f2fab..51d02b758e 100644 --- a/src/component/toolbox/feature/Brush.ts +++ b/src/component/toolbox/feature/Brush.ts @@ -63,13 +63,12 @@ class BrushFeature extends ToolboxFeature { zrUtil.each(featureModel.get('type', true), function (type) { featureModel.setIconStatus( type, - ( - type === 'keep' - ? brushMode === 'multiple' - : type === 'clear' - ? isBrushed - : type === brushType - ) ? 'emphasis' : 'normal' + type === 'clear' + // The clear button is not a toggle. The emphasis indicates that it is available. + ? (isBrushed ? 'emphasis' : 'normal') + : (type === 'keep' ? brushMode === 'multiple' : type === brushType) + ? 'select' + : 'normal' ); }); } diff --git a/src/component/toolbox/feature/DataZoom.ts b/src/component/toolbox/feature/DataZoom.ts index 0c57324def..0e23864217 100644 --- a/src/component/toolbox/feature/DataZoom.ts +++ b/src/component/toolbox/feature/DataZoom.ts @@ -277,6 +277,7 @@ function updateBackBtnStatus( featureModel: ToolboxDataZoomFeatureModel, ecModel: GlobalModel ) { + // The back button is not a toggle. The emphasis indicates that it is available. featureModel.setIconStatus( 'back', history.count(ecModel) > 1 ? 'emphasis' : 'normal' @@ -299,7 +300,7 @@ function updateZoomBtnStatus( view._isZoomActive = zoomActive; - featureModel.setIconStatus('zoom', zoomActive ? 'emphasis' : 'normal'); + featureModel.setIconStatus('zoom', zoomActive ? 'select' : 'normal'); const brushTargetManager = new BrushTargetManager( makeAxisFinder(featureModel), diff --git a/src/component/toolbox/feature/MagicType.ts b/src/component/toolbox/feature/MagicType.ts index e13d6db6cd..6a2a8b230c 100644 --- a/src/component/toolbox/feature/MagicType.ts +++ b/src/component/toolbox/feature/MagicType.ts @@ -143,7 +143,7 @@ class MagicType extends ToolboxFeature { } }); - model.setIconStatus(type, 'emphasis'); + model.setIconStatus(type, 'select'); ecModel.eachComponent( { @@ -166,7 +166,7 @@ class MagicType extends ToolboxFeature { tiled: model.option.title.stack }, model.option.title); - if (model.get(['iconStatus', type]) !== 'emphasis') { + if (model.get(['iconStatus', type]) !== 'select') { currentType = 'tiled'; } } @@ -226,7 +226,7 @@ const seriesOptGenreator: Record = { 'stack': function (seriesType, seriesId, seriesModel, model) { const isStack = seriesModel.get('stack') === INNER_STACK_KEYWORD; if (seriesType === 'line' || seriesType === 'bar') { - model.setIconStatus('stack', isStack ? 'normal' : 'emphasis'); + model.setIconStatus('stack', isStack ? 'normal' : 'select'); return zrUtil.merge({ id: seriesId, stack: isStack ? '' : INNER_STACK_KEYWORD diff --git a/src/component/toolbox/featureManager.ts b/src/component/toolbox/featureManager.ts index 65b0917bc9..959c8dee06 100644 --- a/src/component/toolbox/featureManager.ts +++ b/src/component/toolbox/featureManager.ts @@ -50,6 +50,14 @@ export interface ToolboxFeatureOption { emphasis?: { iconStyle?: IconStyle } + /** + * The style of the icons in `select` status, that is, the icons that are currently + * toggled on, such as the icon of the current type in `magicType`. + * If not specified, `emphasis.iconStyle` is used. + */ + select?: { + iconStyle?: ItemStyleOption + } iconStatus?: Partial> diff --git a/test/toolbox-select-iconStyle.html b/test/toolbox-select-iconStyle.html new file mode 100644 index 0000000000..5445e921ae --- /dev/null +++ b/test/toolbox-select-iconStyle.html @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/ut/spec/component/toolbox/iconStatus.test.ts b/test/ut/spec/component/toolbox/iconStatus.test.ts new file mode 100644 index 0000000000..d09efff604 --- /dev/null +++ b/test/ut/spec/component/toolbox/iconStatus.test.ts @@ -0,0 +1,313 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { createChart, getECModel } from '../../../core/utHelper'; +import { EChartsType } from '../../../../../src/echarts'; +import { ToolboxComponentOption } from '../../../../../src/export/option'; +import ToolboxView from '../../../../../src/component/toolbox/ToolboxView'; +import { ToolboxFeature } from '../../../../../src/component/toolbox/featureManager'; +import { HOVER_STATE_EMPHASIS, HOVER_STATE_NORMAL } from '../../../../../src/util/states'; +import { ECElement } from '../../../../../src/util/types'; +import Path from 'zrender/src/graphic/Path'; +import { ElementEvent } from 'zrender/src/Element'; + + +describe('toolbox_iconStatus', function () { + + let chart: EChartsType; + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + function setOption(toolbox: ToolboxComponentOption): void { + chart.setOption({ + animation: false, + toolbox: toolbox, + brush: {xAxisIndex: 0}, + xAxis: {type: 'category', data: ['a', 'b', 'c']}, + yAxis: {}, + series: [{type: 'bar', data: [1, 2, 3]}] + }); + } + + // The icon paths are re-created whenever toolbox is re-rendered. Always fetch them when needed. + function getFeature(featureName: string): ToolboxFeature { + const toolboxModel = getECModel(chart).getComponent('toolbox'); + // @ts-ignore + const toolboxView = chart._componentsMap[toolboxModel.__viewId] as ToolboxView; + return toolboxView._features.get(featureName) as ToolboxFeature; + } + + function getIcon(featureName: string, iconName: string): Path & ECElement { + return getFeature(featureName).model.iconPaths[iconName] as Path & ECElement; + } + + function trigger(icon: Path, eventName: 'click' | 'mouseover' | 'mouseout'): void { + icon.trigger(eventName, {} as ElementEvent); + } + + function click(featureName: string, iconName: string): void { + trigger(getIcon(featureName, iconName), 'click'); + } + + function expectStatus( + featureName: string, + iconName: string, + status: 'normal' | 'emphasis' | 'select' + ): void { + const icon = getIcon(featureName, iconName); + expect(!!icon.selected).toEqual(status === 'select'); + expect(icon.hoverState || HOVER_STATE_NORMAL).toEqual( + status === 'emphasis' ? HOVER_STATE_EMPHASIS : HOVER_STATE_NORMAL + ); + expect(icon.currentStates).toEqual(status === 'normal' ? [] : [status]); + } + + describe('magicType', function () { + + it('should_not_select_any_icon_initially', function () { + setOption({feature: {magicType: {type: ['line', 'bar', 'stack']}}}); + + expectStatus('magicType', 'line', 'normal'); + expectStatus('magicType', 'bar', 'normal'); + expectStatus('magicType', 'stack', 'normal'); + }); + + it('should_select_the_clicked_type_and_unselect_the_other', function () { + setOption({feature: {magicType: {type: ['line', 'bar', 'stack']}}}); + + click('magicType', 'line'); + expect(getECModel(chart).getSeriesByIndex(0).subType).toEqual('line'); + expectStatus('magicType', 'line', 'select'); + expectStatus('magicType', 'bar', 'normal'); + expectStatus('magicType', 'stack', 'normal'); + + click('magicType', 'bar'); + expect(getECModel(chart).getSeriesByIndex(0).subType).toEqual('bar'); + expectStatus('magicType', 'line', 'normal'); + expectStatus('magicType', 'bar', 'select'); + expectStatus('magicType', 'stack', 'normal'); + }); + + it('should_toggle_stack_without_affecting_line_and_bar', function () { + setOption({feature: {magicType: {type: ['line', 'bar', 'stack']}}}); + + click('magicType', 'line'); + click('magicType', 'stack'); + expectStatus('magicType', 'line', 'select'); + expectStatus('magicType', 'stack', 'select'); + + click('magicType', 'stack'); + expectStatus('magicType', 'line', 'select'); + expectStatus('magicType', 'stack', 'normal'); + }); + + it('should_keep_selected_after_mouseout', function () { + setOption({feature: {magicType: {type: ['line', 'bar']}}}); + + click('magicType', 'line'); + const icon = getIcon('magicType', 'line'); + + trigger(icon, 'mouseover'); + expect(icon.selected).toEqual(true); + expect(icon.hoverState).toEqual(HOVER_STATE_EMPHASIS); + + trigger(icon, 'mouseout'); + expect(icon.selected).toEqual(true); + expect(icon.hoverState).toEqual(HOVER_STATE_NORMAL); + }); + }); + + describe('select_iconStyle', function () { + + it('should_use_select_iconStyle_of_feature', function () { + setOption({ + feature: { + magicType: { + type: ['line', 'bar'], + iconStyle: {borderColor: '#111'}, + emphasis: {iconStyle: {borderColor: '#222'}}, + select: {iconStyle: {borderColor: '#333', borderWidth: 3}} + } + } + }); + + click('magicType', 'line'); + + const selected = getIcon('magicType', 'line'); + expect(selected.states.select.style.stroke).toEqual('#333'); + expect(selected.states.emphasis.style.stroke).toEqual('#222'); + expect(selected.style.stroke).toEqual('#333'); + expect(selected.style.lineWidth).toEqual(3); + + expect(getIcon('magicType', 'bar').style.stroke).toEqual('#111'); + }); + + it('should_use_select_iconStyle_of_toolbox_if_not_specified_on_feature', function () { + setOption({ + iconStyle: {borderColor: '#111'}, + emphasis: {iconStyle: {borderColor: '#222'}}, + select: {iconStyle: {borderColor: '#333'}}, + feature: { + magicType: {type: ['line', 'bar']}, + dataZoom: { + select: {iconStyle: {borderColor: '#444'}} + } + } + }); + + click('magicType', 'line'); + expect(getIcon('magicType', 'line').style.stroke).toEqual('#333'); + expect(getIcon('magicType', 'bar').style.stroke).toEqual('#111'); + + click('dataZoom', 'zoom'); + expect(getIcon('dataZoom', 'zoom').style.stroke).toEqual('#444'); + }); + + it('should_fall_back_to_emphasis_iconStyle_if_not_specified', function () { + setOption({ + emphasis: {iconStyle: {borderColor: '#222'}}, + feature: { + magicType: { + type: ['line', 'bar'], + iconStyle: {borderColor: '#111'} + } + } + }); + + click('magicType', 'line'); + + expectStatus('magicType', 'line', 'select'); + const selected = getIcon('magicType', 'line'); + expect(selected.states.select.style).toEqual(selected.states.emphasis.style); + expect(selected.style.stroke).toEqual('#222'); + + expect(getIcon('magicType', 'bar').style.stroke).toEqual('#111'); + }); + }); + + describe('iconStatus_option', function () { + + it('should_support_select_specified_in_option', function () { + setOption({ + feature: { + magicType: { + type: ['line', 'bar'], + iconStatus: {bar: 'select'} + } + } + }); + + expectStatus('magicType', 'line', 'normal'); + expectStatus('magicType', 'bar', 'select'); + + click('magicType', 'line'); + expectStatus('magicType', 'line', 'select'); + expectStatus('magicType', 'bar', 'normal'); + }); + + it('should_still_support_emphasis_specified_in_option', function () { + setOption({ + feature: { + magicType: { + type: ['line', 'bar'], + iconStatus: {bar: 'emphasis'} + } + } + }); + + expectStatus('magicType', 'line', 'normal'); + expectStatus('magicType', 'bar', 'emphasis'); + + click('magicType', 'line'); + expectStatus('magicType', 'line', 'select'); + expectStatus('magicType', 'bar', 'normal'); + }); + }); + + describe('dataZoom', function () { + + it('should_toggle_select_of_zoom', function () { + setOption({feature: {dataZoom: {}}}); + + expectStatus('dataZoom', 'zoom', 'normal'); + expectStatus('dataZoom', 'back', 'normal'); + + click('dataZoom', 'zoom'); + expectStatus('dataZoom', 'zoom', 'select'); + expectStatus('dataZoom', 'back', 'normal'); + + click('dataZoom', 'zoom'); + expectStatus('dataZoom', 'zoom', 'normal'); + }); + }); + + describe('brush', function () { + + it('should_select_the_current_brush_type_and_mode', function () { + setOption({feature: {brush: {}}}); + + expectStatus('brush', 'rect', 'normal'); + expectStatus('brush', 'keep', 'normal'); + expectStatus('brush', 'clear', 'normal'); + + click('brush', 'rect'); + expectStatus('brush', 'rect', 'select'); + expectStatus('brush', 'polygon', 'normal'); + + click('brush', 'polygon'); + expectStatus('brush', 'rect', 'normal'); + expectStatus('brush', 'polygon', 'select'); + + click('brush', 'keep'); + expectStatus('brush', 'keep', 'select'); + expectStatus('brush', 'polygon', 'select'); + + click('brush', 'polygon'); + expectStatus('brush', 'polygon', 'normal'); + expectStatus('brush', 'keep', 'select'); + }); + + it('should_use_emphasis_rather_than_select_for_clear', function () { + setOption({feature: {brush: {}}}); + + chart.dispatchAction({ + type: 'brush', + areas: [{ + xAxisIndex: 0, + brushType: 'lineX', + coordRange: [0, 1] + }] + }); + // The action `brush` does not re-render toolbox. The icons are updated in the next update. + chart.setOption({}); + expectStatus('brush', 'clear', 'emphasis'); + + click('brush', 'clear'); + chart.setOption({}); + expectStatus('brush', 'clear', 'normal'); + }); + }); + +});