highcharts
Version:
JavaScript charting framework
556 lines (555 loc) • 20.6 kB
JavaScript
/* *
*
* (c) 2009-2025 Øystein Moseng
*
* Accessibility component for chart info region and table.
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import A11yI18n from '../A11yI18n.js';
import AccessibilityComponent from '../AccessibilityComponent.js';
import Announcer from '../Utils/Announcer.js';
import AnnotationsA11y from './AnnotationsA11y.js';
const { getAnnotationsInfoHTML } = AnnotationsA11y;
import AST from '../../Core/Renderer/HTML/AST.js';
import CU from '../Utils/ChartUtilities.js';
const { getAxisDescription, getAxisRangeDescription, getChartTitle, unhideChartElementFromAT } = CU;
import F from '../../Core/Templating.js';
const { format } = F;
import H from '../../Core/Globals.js';
const { doc } = H;
import HU from '../Utils/HTMLUtilities.js';
const { addClass, getElement, getHeadingTagNameForElement, stripHTMLTagsFromString, visuallyHideElement } = HU;
import U from '../../Core/Utilities.js';
const { attr, pick, replaceNested } = U;
/* *
*
* Functions
*
* */
/* eslint-disable valid-jsdoc */
/**
* @private
*/
function getTableSummary(chart) {
return chart.langFormat('accessibility.table.tableSummary', { chart: chart });
}
/**
* @private
*/
function getTypeDescForMapChart(chart, formatContext) {
return formatContext.mapTitle ?
chart.langFormat('accessibility.chartTypes.mapTypeDescription', formatContext) :
chart.langFormat('accessibility.chartTypes.unknownMap', formatContext);
}
/**
* @private
*/
function getTypeDescForCombinationChart(chart, formatContext) {
return chart.langFormat('accessibility.chartTypes.combinationChart', formatContext);
}
/**
* @private
*/
function getTypeDescForEmptyChart(chart, formatContext) {
return chart.langFormat('accessibility.chartTypes.emptyChart', formatContext);
}
/**
* @private
*/
function buildTypeDescriptionFromSeries(chart, types, context) {
const firstType = types[0], typeExplanation = chart.langFormat('accessibility.seriesTypeDescriptions.' + firstType, context), multi = chart.series && chart.series.length < 2 ? 'Single' : 'Multiple';
return (chart.langFormat('accessibility.chartTypes.' + firstType + multi, context) ||
chart.langFormat('accessibility.chartTypes.default' + multi, context)) + (typeExplanation ? ' ' + typeExplanation : '');
}
/**
* Return simplified explanation of chart type. Some types will not be
* familiar to most users, but in those cases we try to add an explanation
* of the type.
*
* @private
* @function Highcharts.Chart#getTypeDescription
* @param {Array<string>} types The series types in this chart.
* @return {string} The text description of the chart type.
*/
function getTypeDescription(chart, types) {
const firstType = types[0], firstSeries = chart.series && chart.series[0] || {}, mapTitle = chart.mapView && chart.mapView.geoMap &&
chart.mapView.geoMap.title, formatContext = {
numSeries: chart.series.length,
numPoints: firstSeries.points && firstSeries.points.length,
chart,
mapTitle
};
if (!firstType) {
return getTypeDescForEmptyChart(chart, formatContext);
}
if (firstType === 'map' || firstType === 'tiledwebmap') {
return getTypeDescForMapChart(chart, formatContext);
}
if (chart.types.length > 1) {
return getTypeDescForCombinationChart(chart, formatContext);
}
return buildTypeDescriptionFromSeries(chart, types, formatContext);
}
/**
* @private
*/
function stripEmptyHTMLTags(str) {
// Scan alert #[71]: Loop for nested patterns
return replaceNested(str, [/<([\w\-.:!]+)\b[^<>]*>\s*<\/\1>/g, '']);
}
/* *
*
* Class
*
* */
/**
* The InfoRegionsComponent class
*
* @private
* @class
* @name Highcharts.InfoRegionsComponent
*/
class InfoRegionsComponent extends AccessibilityComponent {
constructor() {
/* *
*
* Properties
*
* */
super(...arguments);
this.screenReaderSections = {};
}
/* *
*
* Functions
*
* */
/* eslint-disable valid-jsdoc */
/**
* Init the component
* @private
*/
init() {
const chart = this.chart;
const component = this;
this.initRegionsDefinitions();
this.addEvent(chart, 'afterGetTableAST', function (e) {
component.onDataTableCreated(e);
});
this.addEvent(chart, 'afterViewData', function (e) {
if (e.wasHidden) {
component.dataTableDiv = e.element;
// Use a small delay to give browsers & AT time to
// register the new table.
setTimeout(function () {
component.focusDataTable();
}, 300);
}
});
this.addEvent(chart, 'afterHideData', function () {
if (component.viewDataTableButton) {
component.viewDataTableButton
.setAttribute('aria-expanded', 'false');
}
});
if (chart.exporting) {
// Needed when print logic in exporting does not trigger
// rerendering thus repositioning of screen reader DOM elements
// (#21554)
this.addEvent(chart, 'afterPrint', function () {
component.updateAllScreenReaderSections();
});
}
this.announcer = new Announcer(chart, 'assertive');
}
/**
* @private
*/
initRegionsDefinitions() {
const component = this, accessibilityOptions = this.chart.options.accessibility;
this.screenReaderSections = {
before: {
element: null,
buildContent: function (chart) {
const formatter = accessibilityOptions.screenReaderSection
.beforeChartFormatter;
return formatter ? formatter(chart) :
component.defaultBeforeChartFormatter(chart);
},
insertIntoDOM: function (el, chart) {
chart.renderTo.insertBefore(el, chart.renderTo.firstChild);
},
afterInserted: function () {
if (typeof component.sonifyButtonId !== 'undefined') {
component.initSonifyButton(component.sonifyButtonId);
}
if (typeof component.dataTableButtonId !== 'undefined') {
component.initDataTableButton(component.dataTableButtonId);
}
}
},
after: {
element: null,
buildContent: function (chart) {
const formatter = accessibilityOptions.screenReaderSection
.afterChartFormatter;
return formatter ? formatter(chart) :
component.defaultAfterChartFormatter();
},
insertIntoDOM: function (el, chart) {
chart.renderTo.insertBefore(el, chart.container.nextSibling);
},
afterInserted: function () {
if (component.chart.accessibility &&
accessibilityOptions.keyboardNavigation.enabled) {
component.chart.accessibility
.keyboardNavigation.updateExitAnchor(); // #15986
}
}
}
};
}
/**
* Called on chart render. Have to update the sections on render, in order
* to get a11y info from series.
*/
onChartRender() {
this.linkedDescriptionElement = this.getLinkedDescriptionElement();
this.setLinkedDescriptionAttrs();
this.updateAllScreenReaderSections();
}
updateAllScreenReaderSections() {
const component = this;
Object.keys(this.screenReaderSections).forEach(function (regionKey) {
component.updateScreenReaderSection(regionKey);
});
}
/**
* @private
*/
getLinkedDescriptionElement() {
const chartOptions = this.chart.options, linkedDescOption = chartOptions.accessibility.linkedDescription;
if (!linkedDescOption) {
return;
}
if (typeof linkedDescOption !== 'string') {
return linkedDescOption;
}
const query = format(linkedDescOption, this.chart), queryMatch = doc.querySelectorAll(query);
if (queryMatch.length === 1) {
return queryMatch[0];
}
}
/**
* @private
*/
setLinkedDescriptionAttrs() {
const el = this.linkedDescriptionElement;
if (el) {
el.setAttribute('aria-hidden', 'true');
addClass(el, 'highcharts-linked-description');
}
}
/**
* @private
* @param {string} regionKey
* The name/key of the region to update
*/
updateScreenReaderSection(regionKey) {
const chart = this.chart;
const region = this.screenReaderSections[regionKey];
const content = region.buildContent(chart);
const sectionDiv = region.element = (region.element || this.createElement('div'));
const hiddenDiv = (sectionDiv.firstChild || this.createElement('div'));
if (content) {
this.setScreenReaderSectionAttribs(sectionDiv, regionKey);
AST.setElementHTML(hiddenDiv, content);
sectionDiv.appendChild(hiddenDiv);
region.insertIntoDOM(sectionDiv, chart);
if (chart.styledMode) {
addClass(hiddenDiv, 'highcharts-visually-hidden');
}
else {
visuallyHideElement(hiddenDiv);
}
unhideChartElementFromAT(chart, hiddenDiv);
if (region.afterInserted) {
region.afterInserted();
}
}
else {
if (sectionDiv.parentNode) {
sectionDiv.parentNode.removeChild(sectionDiv);
}
region.element = null;
}
}
/**
* Apply a11y attributes to a screen reader info section
* @private
* @param {Highcharts.HTMLDOMElement} sectionDiv The section element
* @param {string} regionKey Name/key of the region we are setting attrs for
*/
setScreenReaderSectionAttribs(sectionDiv, regionKey) {
const chart = this.chart, labelText = chart.langFormat('accessibility.screenReaderSection.' + regionKey +
'RegionLabel', { chart: chart, chartTitle: getChartTitle(chart) }), sectionId = `highcharts-screen-reader-region-${regionKey}-${chart.index}`;
attr(sectionDiv, {
id: sectionId,
'aria-label': labelText || void 0
});
// Sections are wrapped to be positioned relatively to chart in case
// elements inside are tabbed to.
sectionDiv.style.position = 'relative';
if (labelText) {
sectionDiv.setAttribute('role', chart.options.accessibility.landmarkVerbosity === 'all' ?
'region' : 'group');
}
}
/**
* @private
*/
defaultBeforeChartFormatter() {
const chart = this.chart, format = chart.options.accessibility.screenReaderSection
.beforeChartFormat;
if (!format) {
return '';
}
const axesDesc = this.getAxesDescription(), shouldHaveSonifyBtn = (chart.sonify &&
chart.options.sonification &&
chart.options.sonification.enabled), sonifyButtonId = 'highcharts-a11y-sonify-data-btn-' +
chart.index, dataTableButtonId = 'hc-linkto-highcharts-data-table-' +
chart.index, annotationsList = getAnnotationsInfoHTML(chart), annotationsTitleStr = chart.langFormat('accessibility.screenReaderSection.annotations.heading', { chart: chart }), context = {
headingTagName: getHeadingTagNameForElement(chart.renderTo),
chartTitle: getChartTitle(chart),
typeDescription: this.getTypeDescriptionText(),
chartSubtitle: this.getSubtitleText(),
chartLongdesc: this.getLongdescText(),
xAxisDescription: axesDesc.xAxis,
yAxisDescription: axesDesc.yAxis,
playAsSoundButton: shouldHaveSonifyBtn ?
this.getSonifyButtonText(sonifyButtonId) : '',
viewTableButton: chart.exporting?.getCSV ?
this.getDataTableButtonText(dataTableButtonId) : '',
annotationsTitle: annotationsList ? annotationsTitleStr : '',
annotationsList: annotationsList
}, formattedString = A11yI18n.i18nFormat(format, context, chart);
this.dataTableButtonId = dataTableButtonId;
this.sonifyButtonId = sonifyButtonId;
return stripEmptyHTMLTags(formattedString);
}
/**
* @private
*/
defaultAfterChartFormatter() {
const chart = this.chart;
const format = chart.options.accessibility.screenReaderSection
.afterChartFormat;
if (!format) {
return '';
}
const context = { endOfChartMarker: this.getEndOfChartMarkerText() };
const formattedString = A11yI18n.i18nFormat(format, context, chart);
return stripEmptyHTMLTags(formattedString);
}
/**
* @private
*/
getLinkedDescription() {
const el = this.linkedDescriptionElement, content = el && el.innerHTML || '';
return stripHTMLTagsFromString(content, this.chart.renderer.forExport);
}
/**
* @private
*/
getLongdescText() {
const chartOptions = this.chart.options, captionOptions = chartOptions.caption, captionText = captionOptions && captionOptions.text, linkedDescription = this.getLinkedDescription();
return (chartOptions.accessibility.description ||
linkedDescription ||
captionText ||
'');
}
/**
* @private
*/
getTypeDescriptionText() {
const chart = this.chart;
return chart.types ?
chart.options.accessibility.typeDescription ||
getTypeDescription(chart, chart.types) : '';
}
/**
* @private
*/
getDataTableButtonText(buttonId) {
const chart = this.chart, buttonText = chart.langFormat('accessibility.table.viewAsDataTableButtonText', { chart: chart, chartTitle: getChartTitle(chart) });
return '<button id="' + buttonId + '">' + buttonText + '</button>';
}
/**
* @private
*/
getSonifyButtonText(buttonId) {
const chart = this.chart;
if (chart.options.sonification &&
chart.options.sonification.enabled === false) {
return '';
}
const buttonText = chart.langFormat('accessibility.sonification.playAsSoundButtonText', { chart: chart, chartTitle: getChartTitle(chart) });
return '<button id="' + buttonId + '">' + buttonText + '</button>';
}
/**
* @private
*/
getSubtitleText() {
const subtitle = (this.chart.options.subtitle);
return stripHTMLTagsFromString(subtitle && subtitle.text || '', this.chart.renderer.forExport);
}
/**
* @private
*/
getEndOfChartMarkerText() {
const endMarkerId = `highcharts-end-of-chart-marker-${this.chart.index}`, endMarker = getElement(endMarkerId);
if (endMarker) {
return endMarker.outerHTML;
}
const chart = this.chart, markerText = chart.langFormat('accessibility.screenReaderSection.endOfChartMarker', { chart: chart }), id = 'highcharts-end-of-chart-marker-' + chart.index;
return '<div id="' + id + '">' + markerText + '</div>';
}
/**
* @private
* @param {Highcharts.Dictionary<string>} e
*/
onDataTableCreated(e) {
const chart = this.chart;
if (chart.options.accessibility.enabled) {
if (this.viewDataTableButton) {
this.viewDataTableButton.setAttribute('aria-expanded', 'true');
}
const attributes = e.tree.attributes || {};
attributes.tabindex = -1;
attributes.summary = getTableSummary(chart);
e.tree.attributes = attributes;
}
}
/**
* @private
*/
focusDataTable() {
const tableDiv = this.dataTableDiv, table = tableDiv && tableDiv.getElementsByTagName('table')[0];
if (table && table.focus) {
table.focus();
}
}
/**
* @private
* @param {string} sonifyButtonId
*/
initSonifyButton(sonifyButtonId) {
const el = this.sonifyButton = getElement(sonifyButtonId);
const chart = this.chart;
const defaultHandler = (e) => {
if (el) {
el.setAttribute('aria-hidden', 'true');
el.setAttribute('aria-label', '');
}
e.preventDefault();
e.stopPropagation();
const announceMsg = chart.langFormat('accessibility.sonification.playAsSoundClickAnnouncement', { chart: chart });
this.announcer.announce(announceMsg);
setTimeout(() => {
if (el) {
el.removeAttribute('aria-hidden');
el.removeAttribute('aria-label');
}
if (chart.sonify) {
chart.sonify();
}
}, 1000); // Delay to let screen reader speak the button press
};
if (el && chart) {
el.setAttribute('tabindex', -1);
el.onclick = function (e) {
const onPlayAsSoundClick = (chart.options.accessibility &&
chart.options.accessibility.screenReaderSection
.onPlayAsSoundClick);
(onPlayAsSoundClick || defaultHandler).call(this, e, chart);
};
}
}
/**
* Set attribs and handlers for default viewAsDataTable button if exists.
* @private
* @param {string} tableButtonId
*/
initDataTableButton(tableButtonId) {
const el = this.viewDataTableButton = getElement(tableButtonId), chart = this.chart, tableId = tableButtonId.replace('hc-linkto-', '');
if (el) {
attr(el, {
tabindex: -1,
'aria-expanded': !!getElement(tableId)
});
el.onclick = chart.options.accessibility
.screenReaderSection.onViewDataTableClick ||
function () {
chart.exporting?.viewData();
};
}
}
/**
* Return object with text description of each of the chart's axes.
* @private
*/
getAxesDescription() {
const chart = this.chart, shouldDescribeColl = function (collectionKey, defaultCondition) {
const axes = chart[collectionKey];
return axes.length > 1 || axes[0] &&
pick(axes[0].options.accessibility &&
axes[0].options.accessibility.enabled, defaultCondition);
}, hasNoMap = !!chart.types &&
chart.types.indexOf('map') < 0 &&
chart.types.indexOf('treemap') < 0 &&
chart.types.indexOf('tilemap') < 0, hasCartesian = !!chart.hasCartesianSeries, showXAxes = shouldDescribeColl('xAxis', !chart.angular && hasCartesian && hasNoMap), showYAxes = shouldDescribeColl('yAxis', hasCartesian && hasNoMap), desc = {};
if (showXAxes) {
desc.xAxis = this.getAxisDescriptionText('xAxis');
}
if (showYAxes) {
desc.yAxis = this.getAxisDescriptionText('yAxis');
}
return desc;
}
/**
* @private
*/
getAxisDescriptionText(collectionKey) {
const chart = this.chart;
const axes = chart[collectionKey];
return chart.langFormat('accessibility.axis.' + collectionKey + 'Description' + (axes.length > 1 ? 'Plural' : 'Singular'), {
chart: chart,
names: axes.map(function (axis) {
return getAxisDescription(axis);
}),
ranges: axes.map(function (axis) {
return getAxisRangeDescription(axis);
}),
numAxes: axes.length
});
}
/**
* Remove component traces
*/
destroy() {
if (this.announcer) {
this.announcer.destroy();
}
}
}
/* *
*
* Default Export
*
* */
export default InfoRegionsComponent;