diff --git a/src/visual/aria.ts b/src/visual/aria.ts index 2ac7c8f575..8d8cead992 100644 --- a/src/visual/aria.ts +++ b/src/visual/aria.ts @@ -39,6 +39,12 @@ const DEFAULT_OPTION: AriaOption = { const innerSeries = makeInner<{scope: object}, SeriesModel>(); const innerGlobal = makeInner<{scope: object}, GlobalModel>(); +const innerDom = makeInner<{attributes?: Dictionary}, HTMLElement>(); + +interface AriaAttributeState { + originalValue: string | null; + value: string; +} type SeriesTypes = keyof LocaleOption['series']['typeNames']; @@ -47,9 +53,11 @@ export const ariaVisualStageHandler = createSimpleOverallStageHandler2(ariaVisua function ariaVisual(ecModel: GlobalModel, api: ExtensionAPI) { const ariaModel: Model = ecModel.getModel('aria'); + const dom = api.getZr().dom; // See "area enabled" detection code in `GlobalModel.ts`. if (!ariaModel.get('enabled')) { + clearLabelAttributes(dom); return; } @@ -136,7 +144,6 @@ function ariaVisual(ecModel: GlobalModel, api: ExtensionAPI) { } function setLabel() { - const dom = api.getZr().dom; // TODO: support for SSR if (!dom) { return; @@ -147,13 +154,14 @@ function ariaVisual(ecModel: GlobalModel, api: ExtensionAPI) { labelModel.option = zrUtil.defaults(labelModel.option, labelLocale); if (!labelModel.get('enabled')) { + clearLabelAttributes(dom); return; } - dom.setAttribute('role', 'img'); + setLabelAttribute(dom, 'role', 'img'); if (labelModel.get('description')) { - dom.setAttribute('aria-label', labelModel.get('description')); + setLabelAttribute(dom, 'aria-label', labelModel.get('description')); return; } @@ -243,7 +251,7 @@ function ariaVisual(ecModel: GlobalModel, api: ExtensionAPI) { const endSeparator = separatorModel.get('end'); ariaLabel += seriesLabels.join(middleSeparator) + endSeparator; - dom.setAttribute('aria-label', ariaLabel); + setLabelAttribute(dom, 'aria-label', ariaLabel); } } @@ -275,3 +283,33 @@ function ariaVisual(ecModel: GlobalModel, api: ExtensionAPI) { return typeNames[type] || typeNames.chart; } } + +function setLabelAttribute(dom: HTMLElement, name: string, value: string) { + const store = innerDom(dom); + const attributes = store.attributes || (store.attributes = {}); + let attribute = attributes[name]; + const currentValue = dom.getAttribute(name); + if (!attribute || currentValue !== attribute.value) { + attribute = attributes[name] = {originalValue: currentValue, value}; + } + attribute.value = value; + dom.setAttribute(name, value); +} + +function clearLabelAttributes(dom: HTMLElement) { + if (!dom) { + return; + } + const store = innerDom(dom); + zrUtil.each(store.attributes, function (attribute: AriaAttributeState, name: string) { + if (dom.getAttribute(name) === attribute.value) { + if (attribute.originalValue == null) { + dom.removeAttribute(name); + } + else { + dom.setAttribute(name, attribute.originalValue); + } + } + }); + delete store.attributes; +} diff --git a/test/ut/spec/series/aria-columns-exclude.test.ts b/test/ut/spec/series/aria-columns-exclude.test.ts index 9c9d7b3ec0..d1ebec28c7 100644 --- a/test/ut/spec/series/aria-columns-exclude.test.ts +++ b/test/ut/spec/series/aria-columns-exclude.test.ts @@ -98,3 +98,113 @@ describe('aria, omit data', function () { }); }); + +describe('aria label lifecycle', function () { + let chart: EChartsType; + + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + function enableLabel(description = 'Generated description') { + chart.setOption({ aria: { enabled: true, label: { enabled: true, description } } }); + } + + it.each(['aria', 'label', 'notMerge'])('removes generated attributes after disabling %s', function (mode) { + enableLabel(); + const dom = chart.getDom(); + expect(dom.getAttribute('role')).toBe('img'); + expect(dom.getAttribute('aria-label')).toBe('Generated description'); + + if (mode === 'aria') { + chart.setOption({ aria: { enabled: false } }); + } + else if (mode === 'label') { + chart.setOption({ aria: { label: { enabled: false } } }); + } + else { + chart.setOption({}, { notMerge: true }); + } + + expect(dom.hasAttribute('role')).toBe(false); + expect(dom.hasAttribute('aria-label')).toBe(false); + }); + + it('removes an automatically generated series description when disabled', function () { + chart.setOption({ + aria: { enabled: true }, + series: [{ type: 'pie', data: [{ name: 'Item', value: 1 }] }] + }); + const dom = chart.getDom(); + expect(dom.getAttribute('aria-label')).toContain('Item'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.hasAttribute('role')).toBe(false); + expect(dom.hasAttribute('aria-label')).toBe(false); + }); + + it('restores host attributes that existed before generation', function () { + const dom = chart.getDom(); + dom.setAttribute('role', 'application'); + dom.setAttribute('aria-label', 'Host description'); + enableLabel(); + enableLabel('Updated generated description'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.getAttribute('role')).toBe('application'); + expect(dom.getAttribute('aria-label')).toBe('Host description'); + }); + + it('leaves host attributes alone when ECharts never generated a label', function () { + const dom = chart.getDom(); + dom.setAttribute('role', 'application'); + dom.setAttribute('aria-label', 'Host description'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.getAttribute('role')).toBe('application'); + expect(dom.getAttribute('aria-label')).toBe('Host description'); + }); + + it('preserves an application override while removing the other generated attribute', function () { + const dom = chart.getDom(); + enableLabel(); + dom.setAttribute('role', 'group'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.getAttribute('role')).toBe('group'); + expect(dom.hasAttribute('aria-label')).toBe(false); + }); + + it('preserves host changes made after label generation', function () { + const dom = chart.getDom(); + enableLabel(); + dom.setAttribute('role', 'group'); + dom.setAttribute('aria-label', 'Replacement host description'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.getAttribute('role')).toBe('group'); + expect(dom.getAttribute('aria-label')).toBe('Replacement host description'); + }); + + it('restores host changes captured by a later generated update', function () { + const dom = chart.getDom(); + enableLabel(); + dom.setAttribute('role', 'group'); + dom.setAttribute('aria-label', 'Replacement host description'); + enableLabel('Updated generated description'); + chart.setOption({}, { notMerge: true }); + expect(dom.getAttribute('role')).toBe('group'); + expect(dom.getAttribute('aria-label')).toBe('Replacement host description'); + }); + + it('generates the current label again after re-enabling', function () { + const dom = chart.getDom(); + enableLabel(); + chart.setOption({ aria: { label: { enabled: false } } }); + enableLabel('Current description'); + expect(dom.getAttribute('role')).toBe('img'); + expect(dom.getAttribute('aria-label')).toBe('Current description'); + chart.setOption({ aria: { enabled: false } }); + expect(dom.hasAttribute('role')).toBe(false); + expect(dom.hasAttribute('aria-label')).toBe(false); + }); +});