| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897 | /** * Accessibility module - Screen Reader support * * (c) 2010-2017 Highsoft AS * Author: Oystein Moseng * * License: www.highcharts.com/license */'use strict';import H from '../parts/Globals.js';import '../parts/Utilities.js';import '../parts/Chart.js';import '../parts/Series.js';import '../parts/Point.js';var win = H.win,    doc = win.document,    erase = H.erase,    addEvent = H.addEvent,    merge = H.merge,    // CSS style to hide element from visual users while still exposing it to    // screen readers    hiddenStyle = {        position: 'absolute',        left: '-9999px',        top: 'auto',        width: '1px',        height: '1px',        overflow: 'hidden'    };// If a point has one of the special keys defined, we expose all keys to the// screen reader.H.Series.prototype.commonKeys = ['name', 'id', 'category', 'x', 'value', 'y'];H.Series.prototype.specialKeys = [    'z', 'open', 'high', 'q3', 'median', 'q1', 'low', 'close'];if (H.seriesTypes.pie) {    // A pie is always simple. Don't quote me on that.    H.seriesTypes.pie.prototype.specialKeys = [];}/** * HTML encode some characters vulnerable for XSS. * * @private * @function htmlencode * * @param {string} html *        The input string. * * @return {string} *         The excaped string. */function htmlencode(html) {    return html        .replace(/&/g, '&')        .replace(/</g, '<')        .replace(/>/g, '>')        .replace(/"/g, '"')        .replace(/'/g, ''')        .replace(/\//g, '/');}/** * Strip HTML tags away from a string. Used for aria-label attributes, painting * on a canvas will fail if the text contains tags. * * @private * @function stripTags * * @param {string} s *        The input string. * * @return {string} *         The filtered string. */function stripTags(s) {    return typeof s === 'string' ? s.replace(/<\/?[^>]+(>|$)/g, '') : s;}// Accessibility optionsH.setOptions({    /**     * Options for configuring accessibility for the chart. Requires the     * [accessibility module](https://code.highcharts.com/modules/accessibility.js)     * to be loaded. For a description of the module and information     * on its features, see     * [Highcharts Accessibility](http://www.highcharts.com/docs/chart-concepts/accessibility).     *     * @since        5.0.0     * @optionparent accessibility     */    accessibility: {        /**         * Whether or not to add series descriptions to charts with a single         * series.         *         * @type      {boolean}         * @default   false         * @since     5.0.0         * @apioption accessibility.describeSingleSeries         */        /**         * Function to run upon clicking the "View as Data Table" link in the         * screen reader region.         *         * By default Highcharts will insert and set focus to a data table         * representation of the chart.         *         * @type      {Function}         * @since     5.0.0         * @apioption accessibility.onTableAnchorClick         */        /**         * Date format to use for points on datetime axes when describing them         * to screen reader users.         *         * Defaults to the same format as in tooltip.         *         * For an overview of the replacement codes, see         * [dateFormat](/class-reference/Highcharts#dateFormat).         *         * @see [pointDateFormatter](#accessibility.pointDateFormatter)         *         * @type      {string}         * @since     5.0.0         * @apioption accessibility.pointDateFormat         */        /**         * Formatter function to determine the date/time format used with         * points on datetime axes when describing them to screen reader users.         * Receives one argument, `point`, referring to the point to describe.         * Should return a date format string compatible with         * [dateFormat](/class-reference/Highcharts#dateFormat).         *         * @see [pointDateFormat](#accessibility.pointDateFormat)         *         * @type      {Function}         * @since     5.0.0         * @apioption accessibility.pointDateFormatter         */        /**         * Formatter function to use instead of the default for point         * descriptions.         * Receives one argument, `point`, referring to the point to describe.         * Should return a String with the description of the point for a screen         * reader user.         *         * @see [point.description](#series.line.data.description)         *         * @type      {Function}         * @since     5.0.0         * @apioption accessibility.pointDescriptionFormatter         */        /**         * Formatter function to use instead of the default for series         * descriptions. Receives one argument, `series`, referring to the         * series to describe. Should return a String with the description of         * the series for a screen reader user.         *         * @see [series.description](#plotOptions.series.description)         *         * @type      {Function}         * @since     5.0.0         * @apioption accessibility.seriesDescriptionFormatter         */        /**         * Enable accessibility features for the chart.         *         * @since 5.0.0         */        enabled: true,        /**         * When a series contains more points than this, we no longer expose         * information about individual points to screen readers.         *         * Set to `false` to disable.         *         * @type  {false|number}         * @since 5.0.0         */        pointDescriptionThreshold: false, // set to false to disable        /**         * A formatter function to create the HTML contents of the hidden screen         * reader information region. Receives one argument, `chart`, referring         * to the chart object. Should return a String with the HTML content         * of the region.         *         * The link to view the chart as a data table will be added         * automatically after the custom HTML content.         *         * @type    {Function}         * @default undefined         * @since   5.0.0         */        screenReaderSectionFormatter: function (chart) {            var options = chart.options,                chartTypes = chart.types || [],                formatContext = {                    chart: chart,                    numSeries: chart.series && chart.series.length                },                // Build axis info - but not for pies and maps. Consider not                // adding for certain other types as well (funnel, pyramid?)                axesDesc = (                    chartTypes.length === 1 && chartTypes[0] === 'pie' ||                    chartTypes[0] === 'map'                ) && {} || chart.getAxesDescription();            return '<div>' + chart.langFormat(                'accessibility.navigationHint', formatContext            ) + '</div><h3>' +                    (                        options.title.text ?                            htmlencode(options.title.text) :                            chart.langFormat(                                'accessibility.defaultChartTitle', formatContext                            )                    ) +                    (                        options.subtitle && options.subtitle.text ?                            '. ' + htmlencode(options.subtitle.text) :                            ''                    ) +                    '</h3><h4>' + chart.langFormat(                'accessibility.longDescriptionHeading', formatContext            ) + '</h4><div>' +                    (                        options.chart.description || chart.langFormat(                            'accessibility.noDescription', formatContext                        )                    ) +                    '</div><h4>' + chart.langFormat(                'accessibility.structureHeading', formatContext            ) + '</h4><div>' +                    (                        options.chart.typeDescription ||                        chart.getTypeDescription()                    ) + '</div>' +                    (axesDesc.xAxis ? (                        '<div>' + axesDesc.xAxis + '</div>'                    ) : '') +                    (axesDesc.yAxis ? (                        '<div>' + axesDesc.yAxis + '</div>'                    ) : '');        }    }});/** * A text description of the chart. * * If the Accessibility module is loaded, this is included by default * as a long description of the chart and its contents in the hidden * screen reader information region. * * @see [typeDescription](#chart.typeDescription) * * @type      {string} * @since     5.0.0 * @apioption chart.description *//** * A text description of the chart type. * * If the Accessibility module is loaded, this will be included in the * description of the chart in the screen reader information region. * * * Highcharts will by default attempt to guess the chart type, but for * more complex charts it is recommended to specify this property for * clarity. * * @type      {string} * @since     5.0.0 * @apioption chart.typeDescription *//** * Utility function. Reverses child nodes of a DOM element. * * @private * @function reverseChildNodes * * @param {Highcharts.HTMLDOMElement|Highcharts.SVGDOMElement} node */function reverseChildNodes(node) {    var i = node.childNodes.length;    while (i--) {        node.appendChild(node.childNodes[i]);    }}// Whenever drawing series, put info on DOM elementsH.addEvent(H.Series, 'afterRender', function () {    if (this.chart.options.accessibility.enabled) {        this.setA11yDescription();    }});/** * Put accessible info on series and points of a series. * * @private * @function Highcharts.Series#setA11yDescription */H.Series.prototype.setA11yDescription = function () {    var a11yOptions = this.chart.options.accessibility,        firstPointEl = (            this.points &&            this.points.length &&            this.points[0].graphic &&            this.points[0].graphic.element        ),        seriesEl = (            firstPointEl &&            firstPointEl.parentNode || this.graph &&            this.graph.element || this.group &&            this.group.element        ); // Could be tracker series depending on series type    if (seriesEl) {        // For some series types the order of elements do not match the order of        // points in series. In that case we have to reverse them in order for        // AT to read them out in an understandable order        if (seriesEl.lastChild === firstPointEl) {            reverseChildNodes(seriesEl);        }        // Make individual point elements accessible if possible. Note: If        // markers are disabled there might not be any elements there to make        // accessible.        if (            this.points && (                this.points.length < a11yOptions.pointDescriptionThreshold ||                a11yOptions.pointDescriptionThreshold === false            )        ) {            this.points.forEach(function (point) {                if (point.graphic) {                    point.graphic.element.setAttribute('role', 'img');                    point.graphic.element.setAttribute('tabindex', '-1');                    point.graphic.element.setAttribute('aria-label', stripTags(                        point.series.options.pointDescriptionFormatter &&                        point.series.options.pointDescriptionFormatter(point) ||                        a11yOptions.pointDescriptionFormatter &&                        a11yOptions.pointDescriptionFormatter(point) ||                        point.buildPointInfoString()                    ));                }            });        }        // Make series element accessible        if (this.chart.series.length > 1 || a11yOptions.describeSingleSeries) {            seriesEl.setAttribute(                'role',                this.options.exposeElementToA11y ? 'img' : 'region'            );            seriesEl.setAttribute('tabindex', '-1');            seriesEl.setAttribute(                'aria-label',                stripTags(                    a11yOptions.seriesDescriptionFormatter &&                    a11yOptions.seriesDescriptionFormatter(this) ||                    this.buildSeriesInfoString()                )            );        }    }};/** * Return string with information about series. * * @private * @function Highcharts.Series#buildSeriesInfoString * * @return {string} */H.Series.prototype.buildSeriesInfoString = function () {    var chart = this.chart,        desc = this.description || this.options.description,        description = desc && chart.langFormat(            'accessibility.series.description', {                description: desc,                series: this            }        ),        xAxisInfo = chart.langFormat(            'accessibility.series.xAxisDescription',            {                name: this.xAxis && this.xAxis.getDescription(),                series: this            }        ),        yAxisInfo = chart.langFormat(            'accessibility.series.yAxisDescription',            {                name: this.yAxis && this.yAxis.getDescription(),                series: this            }        ),        summaryContext = {            name: this.name || '',            ix: this.index + 1,            numSeries: chart.series.length,            numPoints: this.points.length,            series: this        },        combination = chart.types.length === 1 ? '' : 'Combination',        summary = chart.langFormat(            'accessibility.series.summary.' + this.type + combination,            summaryContext        ) || chart.langFormat(            'accessibility.series.summary.default' + combination,            summaryContext        );    return summary + (description ? ' ' + description : '') + (        chart.yAxis.length > 1 && this.yAxis ?            ' ' + yAxisInfo : ''    ) + (        chart.xAxis.length > 1 && this.xAxis ?            ' ' + xAxisInfo : ''    );};/** * Return string with information about point. * * @private * @function Highcharts.Point#buildPointInfoString * * @return {string} */H.Point.prototype.buildPointInfoString = function () {    var point = this,        series = point.series,        a11yOptions = series.chart.options.accessibility,        infoString = '',        dateTimePoint = series.xAxis && series.xAxis.isDatetimeAxis,        timeDesc =            dateTimePoint &&            series.chart.time.dateFormat(                a11yOptions.pointDateFormatter &&                a11yOptions.pointDateFormatter(point) ||                a11yOptions.pointDateFormat ||                H.Tooltip.prototype.getXDateFormat.call(                    {                        getDateFormat: H.Tooltip.prototype.getDateFormat,                        chart: series.chart                    },                    point,                    series.chart.options.tooltip,                    series.xAxis                ),                point.x            ),        hasSpecialKey = H.find(series.specialKeys, function (key) {            return point[key] !== undefined;        });    // If the point has one of the less common properties defined, display all    // that are defined    if (hasSpecialKey) {        if (dateTimePoint) {            infoString = timeDesc;        }        series.commonKeys.concat(series.specialKeys).forEach(function (key) {            if (point[key] !== undefined && !(dateTimePoint && key === 'x')) {                infoString += (infoString ? '. ' : '') +                    key + ', ' +                    point[key];            }        });    } else {        // Pick and choose properties for a succint label        infoString =            (                this.name ||                timeDesc ||                this.category ||                this.id ||                'x, ' + this.x            ) + ', ' +            (this.value !== undefined ? this.value : this.y);    }    return (this.index + 1) + '. ' + infoString + '.' +        (this.description ? ' ' + this.description : '');};/** * Get descriptive label for axis. * * @private * @function Highcharts.Axis#getDescription * * @return {string} */H.Axis.prototype.getDescription = function () {    return (        this.userOptions && this.userOptions.description ||        this.axisTitle && this.axisTitle.textStr ||        this.options.id ||        this.categories && 'categories' ||        this.isDatetimeAxis && 'Time' ||        'values'    );};// Whenever adding or removing series, keep track of types present in chartaddEvent(H.Series, 'afterInit', function () {    var chart = this.chart;    if (chart.options.accessibility.enabled) {        chart.types = chart.types || [];        // Add type to list if does not exist        if (chart.types.indexOf(this.type) < 0) {            chart.types.push(this.type);        }    }});addEvent(H.Series, 'remove', function () {    var chart = this.chart,        removedSeries = this,        hasType = false;    // Check if any of the other series have the same type as this one.    // Otherwise remove it from the list.    chart.series.forEach(function (s) {        if (            s !== removedSeries &&            chart.types.indexOf(removedSeries.type) < 0        ) {            hasType = true;        }    });    if (!hasType) {        erase(chart.types, removedSeries.type);    }});/** * Return simplified description of chart type. Some types will not be familiar * to most screen reader users, but in those cases we try to add a description * of the type. * * @private * @function Highcharts.Chart#getTypeDescription * * @return {string} */H.Chart.prototype.getTypeDescription = function () {    var firstType = this.types && this.types[0],        firstSeries = this.series && this.series[0] || {},        mapTitle = firstSeries.mapTitle,        typeDesc = this.langFormat(            'accessibility.seriesTypeDescriptions.' + firstType,            { chart: this }        ),        formatContext = {            numSeries: this.series.length,            numPoints: firstSeries.points && firstSeries.points.length,            chart: this,            mapTitle: mapTitle        },        multi = this.series && this.series.length === 1 ? 'Single' : 'Multiple';    if (!firstType) {        return this.langFormat(            'accessibility.chartTypes.emptyChart', formatContext        );    }    if (firstType === 'map') {        return mapTitle ?            this.langFormat(                'accessibility.chartTypes.mapTypeDescription',                formatContext            ) :            this.langFormat(                'accessibility.chartTypes.unknownMap',                formatContext            );    }    if (this.types.length > 1) {        return this.langFormat(            'accessibility.chartTypes.combinationChart', formatContext        );    }    return (        this.langFormat(            'accessibility.chartTypes.' + firstType + multi,            formatContext        ) ||        this.langFormat(            'accessibility.chartTypes.default' + multi,            formatContext        )    ) +    (typeDesc ? ' ' + typeDesc : '');};/** * Return object with text description of each of the chart's axes. * * @private * @function Highcharts.Chart#getAxesDescription * * @return {*} */H.Chart.prototype.getAxesDescription = function () {    var numXAxes = this.xAxis.length,        numYAxes = this.yAxis.length,        desc = {};    if (numXAxes) {        desc.xAxis = this.langFormat(            'accessibility.axis.xAxisDescription' + (                numXAxes > 1 ? 'Plural' : 'Singular'            ),            {                chart: this,                names: this.xAxis.map(function (axis) {                    return axis.getDescription();                }),                numAxes: numXAxes            }        );    }    if (numYAxes) {        desc.yAxis = this.langFormat(            'accessibility.axis.yAxisDescription' + (                numYAxes > 1 ? 'Plural' : 'Singular'            ),            {                chart: this,                names: this.yAxis.map(function (axis) {                    return axis.getDescription();                }),                numAxes: numYAxes            }        );    }    return desc;};/** * Set a11y attribs on exporting menu. * * @private * @function Highcharts.Chart#addAccessibleContextMenuAttribs */H.Chart.prototype.addAccessibleContextMenuAttribs = function () {    var exportList = this.exportDivElements;    if (exportList) {        // Set tabindex on the menu items to allow focusing by script        // Set role to give screen readers a chance to pick up the contents        exportList.forEach(function (item) {            if (item.tagName === 'DIV' &&                !(item.children && item.children.length)) {                item.setAttribute('role', 'menuitem');                item.setAttribute('tabindex', -1);            }        });        // Set accessibility properties on parent div        exportList[0].parentNode.setAttribute('role', 'menu');        exportList[0].parentNode.setAttribute(            'aria-label',            this.langFormat(                'accessibility.exporting.chartMenuLabel', { chart: this }            )        );    }};/** * Add screen reader region to chart. tableId is the HTML id of the table to * focus when clicking the table anchor in the screen reader region. * * @private * @function Highcharts.Chart#addScreenReaderRegion * * @param {string} id * * @param {string} tableId */H.Chart.prototype.addScreenReaderRegion = function (id, tableId) {    var chart = this,        hiddenSection = chart.screenReaderRegion = doc.createElement('div'),        tableShortcut = doc.createElement('h4'),        tableShortcutAnchor = doc.createElement('a'),        chartHeading = doc.createElement('h4');    hiddenSection.setAttribute('id', id);    hiddenSection.setAttribute('role', 'region');    hiddenSection.setAttribute(        'aria-label',        chart.langFormat(            'accessibility.screenReaderRegionLabel', { chart: this }        )    );    hiddenSection.innerHTML = chart.options.accessibility        .screenReaderSectionFormatter(chart);    // Add shortcut to data table if export-data is loaded    if (chart.getCSV) {        tableShortcutAnchor.innerHTML = chart.langFormat(            'accessibility.viewAsDataTable', { chart: chart }        );        tableShortcutAnchor.href = '#' + tableId;        // Make this unreachable by user tabbing        tableShortcutAnchor.setAttribute('tabindex', '-1');        tableShortcutAnchor.onclick =            chart.options.accessibility.onTableAnchorClick || function () {                chart.viewData();                doc.getElementById(tableId).focus();            };        tableShortcut.appendChild(tableShortcutAnchor);        hiddenSection.appendChild(tableShortcut);    }    // Note: JAWS seems to refuse to read aria-label on the container, so add an    // h4 element as title for the chart.    chartHeading.innerHTML = chart.langFormat(        'accessibility.chartHeading', { chart: chart }    );    chart.renderTo.insertBefore(chartHeading, chart.renderTo.firstChild);    chart.renderTo.insertBefore(hiddenSection, chart.renderTo.firstChild);    // Hide the section and the chart heading    merge(true, chartHeading.style, hiddenStyle);    merge(true, hiddenSection.style, hiddenStyle);};// Make chart container accessible, and wrap table functionality.H.Chart.prototype.callbacks.push(function (chart) {    var options = chart.options,        a11yOptions = options.accessibility;    if (!a11yOptions.enabled) {        return;    }    var titleElement,        descElement = chart.container.getElementsByTagName('desc')[0],        textElements = chart.container.getElementsByTagName('text'),        titleId = 'highcharts-title-' + chart.index,        tableId = 'highcharts-data-table-' + chart.index,        hiddenSectionId = 'highcharts-information-region-' + chart.index,        chartTitle = options.title.text || chart.langFormat(            'accessibility.defaultChartTitle', { chart: chart }        ),        svgContainerTitle = stripTags(chart.langFormat(            'accessibility.svgContainerTitle', {                chartTitle: chartTitle            }        ));    // Add SVG title tag if it is set    if (svgContainerTitle.length) {        titleElement = doc.createElementNS(            'http://www.w3.org/2000/svg',            'title'        );        titleElement.textContent = svgContainerTitle;        titleElement.id = titleId;        descElement.parentNode.insertBefore(titleElement, descElement);    }    chart.renderTo.setAttribute('role', 'region');    chart.renderTo.setAttribute(        'aria-label',        chart.langFormat(            'accessibility.chartContainerLabel',            {                title: stripTags(chartTitle),                chart: chart            }        )    );    // Set screen reader properties on export menu    if (        chart.exportSVGElements &&        chart.exportSVGElements[0] &&        chart.exportSVGElements[0].element    ) {        // Set event handler on button        var button = chart.exportSVGElements[0].element,            oldExportCallback = button.onclick;        button.onclick = function () {            oldExportCallback.apply(                this,                Array.prototype.slice.call(arguments)            );            chart.addAccessibleContextMenuAttribs();            chart.highlightExportItem(0);        };        // Set props on button        button.setAttribute('role', 'button');        button.setAttribute(            'aria-label',            chart.langFormat(                'accessibility.exporting.menuButtonLabel', { chart: chart }            )        );        // Set props on group        chart.exportingGroup.element.setAttribute('role', 'region');        chart.exportingGroup.element.setAttribute(            'aria-label',            chart.langFormat(                'accessibility.exporting.exportRegionLabel', { chart: chart }            )        );    }    // Set screen reader properties on input boxes for range selector. We need    // to do this regardless of whether or not these are visible, as they are    // by default part of the page's tabindex unless we set them to -1.    if (chart.rangeSelector) {        ['minInput', 'maxInput'].forEach(function (key, i) {            if (chart.rangeSelector[key]) {                chart.rangeSelector[key].setAttribute('tabindex', '-1');                chart.rangeSelector[key].setAttribute('role', 'textbox');                chart.rangeSelector[key].setAttribute(                    'aria-label',                    chart.langFormat(                        'accessibility.rangeSelector' +                            (i ? 'MaxInput' : 'MinInput'), { chart: chart }                    )                );            }        });    }    // Hide text elements from screen readers    [].forEach.call(textElements, function (el) {        el.setAttribute('aria-hidden', 'true');    });    // Add top-secret screen reader region    chart.addScreenReaderRegion(hiddenSectionId, tableId);    // Add ID and summary attr to table HTML    addEvent(chart, 'afterGetTable', function (e) {        e.html = e.html            .replace(                '<table ',                '<table summary="' + chart.langFormat(                    'accessibility.tableSummary', { chart: chart }                ) + '"'            );    });});
 |